From f5b23e5fc1ceb8372921aa4c7a55b155b780bba5 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Mon, 1 May 2023 15:40:56 +0800 Subject: [PATCH] feat: Support heading block (#2376) * feat: support transform heading block according to markdown * fix: folder scroll --- .../FormatButton.tsx | 4 +- .../FormatIcon.tsx | 2 +- .../index.hooks.ts | 2 +- .../index.tsx | 6 +- .../document/BlockMenu/MenuItem.hooks.ts | 2 +- .../BlockSideToolbar.hooks.tsx} | 2 +- .../index.tsx | 6 +- .../document/HeadingBlock/index.tsx | 18 ++---- .../components/document/Node/index.tsx | 10 +++- .../components/document/Overlay/index.tsx | 4 +- .../components/document/Root/index.tsx | 12 +++- .../document/TextBlock/TextBlock.hooks.ts | 60 ++++++++----------- .../components/document/TextBlock/index.tsx | 10 ++-- .../document/TextBlock/useMarkDown.hooks.ts | 41 +++++++++++++ .../document/_shared/TextInput.hooks.ts | 51 +++++++++++----- .../NavigationPanel/NavigationPanel.tsx | 12 +++- .../constants/{ => document}/block.ts | 0 .../appflowy_app/constants/document/config.ts | 6 ++ .../constants/document/text_block.ts | 10 ++++ .../constants/{ => document}/toolbar.ts | 0 .../src/appflowy_app/interfaces/document.ts | 17 ++++-- .../effects/document/document_controller.ts | 14 ++++- .../{activePageId => active-page-id}/slice.ts | 0 .../document/async-actions/blocks/heading.ts | 42 +++++++++++++ .../blocks/text}/backspace.ts | 19 +++--- .../blocks/text}/delete.ts | 2 +- .../blocks/text}/indent.ts | 5 +- .../async-actions/blocks/text/index.ts | 39 ++++++++++++ .../blocks/text}/insert.ts | 16 ++--- .../blocks/text}/outdent.ts | 0 .../blocks/text}/split.ts | 23 +++---- .../blocks/text}/update.ts | 6 +- .../set_cursor.ts => async-actions/cursor.ts} | 4 +- .../reducers/document/async-actions/index.ts | 7 +++ .../reducers/document/async_actions/index.ts | 6 -- .../stores/reducers/document/slice.ts | 4 +- .../src/appflowy_app/stores/store.ts | 2 +- .../{block.ts => document/blocks/common.ts} | 25 +++++--- .../utils/document/blocks/heading.ts | 22 +++++++ .../utils/document/blocks/text.ts | 6 ++ .../utils/{ => document}/slate/format.ts | 0 .../utils/{ => document}/slate/hotkey.ts | 13 +--- .../utils/document/slate/markdown.ts | 18 ++++++ .../utils/{ => document}/slate/text.ts | 0 .../utils/{ => document}/slate/toolbar.ts | 0 .../subscribe.ts} | 12 ++-- frontend/appflowy_tauri/tsconfig.json | 3 +- frontend/appflowy_tauri/vite.config.ts | 3 +- 48 files changed, 393 insertions(+), 173 deletions(-) rename frontend/appflowy_tauri/src/appflowy_app/components/document/{_shared/HoveringToolbar => BlockHorizontalToolbar}/FormatButton.tsx (87%) rename frontend/appflowy_tauri/src/appflowy_app/components/document/{_shared/HoveringToolbar => BlockHorizontalToolbar}/FormatIcon.tsx (91%) rename frontend/appflowy_tauri/src/appflowy_app/components/document/{_shared/HoveringToolbar => BlockHorizontalToolbar}/index.hooks.ts (91%) rename frontend/appflowy_tauri/src/appflowy_app/components/document/{_shared/HoveringToolbar => BlockHorizontalToolbar}/index.tsx (85%) rename frontend/appflowy_tauri/src/appflowy_app/components/document/{BlockSideTools/BlockSideTools.hooks.tsx => BlockSideToolbar/BlockSideToolbar.hooks.tsx} (97%) rename frontend/appflowy_tauri/src/appflowy_app/components/document/{BlockSideTools => BlockSideToolbar}/index.tsx (88%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts rename frontend/appflowy_tauri/src/appflowy_app/constants/{ => document}/block.ts (100%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts rename frontend/appflowy_tauri/src/appflowy_app/constants/{ => document}/toolbar.ts (100%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/{activePageId => active-page-id}/slice.ts (100%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/backspace.ts (86%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/delete.ts (93%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/indent.ts (84%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/insert.ts (66%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/outdent.ts (100%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/split.ts (68%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions => async-actions/blocks/text}/update.ts (82%) rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/{async_actions/set_cursor.ts => async-actions/cursor.ts} (96%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts rename frontend/appflowy_tauri/src/appflowy_app/utils/{block.ts => document/blocks/common.ts} (86%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts rename frontend/appflowy_tauri/src/appflowy_app/utils/{ => document}/slate/format.ts (100%) rename frontend/appflowy_tauri/src/appflowy_app/utils/{ => document}/slate/hotkey.ts (94%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts rename frontend/appflowy_tauri/src/appflowy_app/utils/{ => document}/slate/text.ts (100%) rename frontend/appflowy_tauri/src/appflowy_app/utils/{ => document}/slate/toolbar.ts (100%) rename frontend/appflowy_tauri/src/appflowy_app/utils/{block_change.ts => document/subscribe.ts} (97%) diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/FormatButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatButton.tsx similarity index 87% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/FormatButton.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatButton.tsx index 903603480e..174af2e1ef 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/FormatButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatButton.tsx @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/FormatIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatIcon.tsx similarity index 91% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/FormatIcon.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatIcon.tsx index 371ec6585c..39aeafebac 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/FormatIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatIcon.tsx @@ -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) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts similarity index 91% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts index 0d3764522a..3347d398f6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts @@ -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(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.tsx similarity index 85% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.tsx index c64e387d70..1ad8dc025e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.tsx @@ -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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts index 359e767da7..f944c07f91 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts @@ -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', diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx similarity index 97% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx index 1187b53180..e4c3b68089 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx @@ -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(''); const [menuOpen, setMenuOpen] = useState(false); const ref = useRef(null); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx similarity index 88% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index d3e361f43f..e319e484fd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx @@ -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 ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx index 252b72b7b9..30af6c677d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx @@ -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 = { - 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 }) { return (
- {/**/} +
); } 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 8338fd8087..cf13e853c3 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 @@ -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) { const { node, childIds, isSelected, ref } = useNode(id); const renderBlock = useCallback(() => { switch (node.type) { - case 'text': { + case BlockType.TextBlock: { return ; } + case BlockType.HeadingBlock: { + return ; + } default: - break; + return null; } }, [node, childIds]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx index 62d15de804..304c09027b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx @@ -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 : } + {isDragging ? null : } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx index 4ce3884400..8069b0e1a4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx @@ -18,9 +18,17 @@ function Root({ documentData }: { documentData: DocumentData }) { return ; } - return ( -
+
{ + // prevent backspace from going back + if (e.key === 'Backspace') { + e.stopPropagation(); + } + }} + >
); 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 63cbc58ce0..9fe898069e 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 @@ -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, 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) => { // 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 { 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 03a2cc1880..d88b73577d 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 @@ -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) { @@ -21,7 +21,7 @@ function TextBlock({ <>
- + { + 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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts index fba074b561..3c45bbeba5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts @@ -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([]); - 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; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx index fc47388757..309c26f8db 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx @@ -105,14 +105,20 @@ export const NavigationPanel = ({
-
-
+
+
-
+
{/**/} diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/block.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/constants/block.ts rename to frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts new file mode 100644 index 0000000000..071e6492a5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts @@ -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]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts new file mode 100644 index 0000000000..fbe973b69c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts @@ -0,0 +1,10 @@ +export const keyBoardEventKeyMap = { + Enter: 'Enter', + Backspace: 'Backspace', + Tab: 'Tab', + Up: 'ArrowUp', + Down: 'ArrowDown', + Left: 'ArrowLeft', + Right: 'ArrowRight', + Space: ' ', +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/toolbar.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/constants/toolbar.ts rename to frontend/appflowy_tauri/src/appflowy_app/constants/document/toolbar.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 6a81dbb022..077ec411e2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -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 extends BlockType.HeadingBlock + ? HeadingBlockData + : Type extends BlockType.PageBlock + ? PageBlockData + : TextBlockData; -export interface NestedBlock { +export interface NestedBlock { id: string; type: BlockType; - data: BlockData | Record; + data: BlockData; parent: string | null; children: string; } @@ -98,3 +103,5 @@ export interface BlockPBValue { children: string; data: string; } + +export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent, Editor]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts index 23afdaa555..f55e26f871 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts @@ -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(null); @@ -46,7 +46,9 @@ export class DocumentController { if (document.ok) { const nodes: DocumentData['nodes'] = {}; get>(document.val, [BLOCK_MAP_NAME]).forEach((block) => { - nodes[block.id] = blockPB2Node(block); + Object.assign(nodes, { + [block.id]: blockPB2Node(block), + }); }); const children: Record = {}; get>(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, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/activePageId/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/active-page-id/slice.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/activePageId/slice.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/active-page-id/slice.ts 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 new file mode 100644 index 0000000000..8fec913504 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts @@ -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 })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts similarity index 86% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts index b9ee8403e5..fbea40b6d3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts @@ -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) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/delete.ts similarity index 93% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/delete.ts index 7bcf90f25d..a81401eadf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/delete.ts @@ -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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts similarity index 84% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts index 9a03823d7c..b7be58b237 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts @@ -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]; 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 new file mode 100644 index 0000000000..fb38ecf363 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts @@ -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 })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts similarity index 66% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts index e8d57ae501..398046b467 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts @@ -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)]); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts similarity index 68% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts index e78ad4f5ca..b500b874ae 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts @@ -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: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts similarity index 82% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts index 99c8c4863c..6e9f0580ff 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts @@ -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', diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts similarity index 96% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts index 98e4b293d3..3cd15bc32a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts @@ -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', diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts new file mode 100644 index 0000000000..be0989f7a9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts deleted file mode 100644 index caeb5e7be6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './delete'; -export * from './indent'; -export * from './insert'; -export * from './backspace'; -export * from './outdent'; -export * from './split'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index 738177a6f9..71648bc85d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -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 { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts index 9bd9c15909..be47237dd1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts @@ -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, diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts similarity index 86% rename from frontend/appflowy_tauri/src/appflowy_app/utils/block.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts index 557959d974..c3af7c1c37 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts @@ -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: BlockType, parentId: string, data: BlockData): NestedBlock { + return { + id: generateId(), + type, + parent: parentId, + children: generateId(), + data, + }; +} 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 new file mode 100644 index 0000000000..948e55f515 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts @@ -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, 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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts new file mode 100644 index 0000000000..8cdf73e41b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts @@ -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, parentId, data); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/format.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/format.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/utils/slate/format.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/format.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/hotkey.ts similarity index 94% rename from frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/hotkey.ts index bf96418c0b..70f5b764f8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/hotkey.ts @@ -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 = { 'mod+b': 'bold', @@ -13,16 +14,6 @@ const HOTKEYS: Record = { '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, editor: Editor) { for (const hotkey in HOTKEYS) { if (isHotkey(hotkey, event)) { 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 new file mode 100644 index 0000000000..721699ab5a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts @@ -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, 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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/text.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/text.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/toolbar.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/toolbar.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block_change.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts similarity index 97% rename from frontend/appflowy_tauri/src/appflowy_app/utils/block_change.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts index df3e348e90..aabc8eacbe 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/block_change.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts @@ -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 { diff --git a/frontend/appflowy_tauri/tsconfig.json b/frontend/appflowy_tauri/tsconfig.json index 7e82aa0265..f6b44e7521 100644 --- a/frontend/appflowy_tauri/tsconfig.json +++ b/frontend/appflowy_tauri/tsconfig.json @@ -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"], diff --git a/frontend/appflowy_tauri/vite.config.ts b/frontend/appflowy_tauri/vite.config.ts index 871e54f6d2..014e017c23 100644 --- a/frontend/appflowy_tauri/vite.config.ts +++ b/frontend/appflowy_tauri/vite.config.ts @@ -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/` }, ], }, });