From 99c48f71002ad7dac10d9795ac66f068e8fb6b70 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Tue, 16 May 2023 10:54:40 +0800 Subject: [PATCH] Support range selection and refactor data update mechanism and optimize outdent/indent operations (#2514) * refactor: simplify data update logic and optimize outdent/indent operations * feat: support range selection * fix: review suggestions --- .../BlockHorizontalToolbar/index.hooks.ts | 1 + .../BlockSelection/NodesRect.hooks.ts | 24 ++- .../BlockSideToolbar.hooks.tsx | 98 +++++---- .../document/BlockSideToolbar/index.tsx | 3 +- .../document/CodeBlock/CodeBlock.hooks.ts | 2 +- .../document/DocumentTitle/index.tsx | 9 +- .../components/document/Node/index.tsx | 17 +- .../components/document/TextBlock/Leaf.tsx | 25 ++- .../document/TextBlock/TextBlock.hooks.ts | 6 +- .../document/TextBlock/events/Events.hooks.ts | 5 +- .../components/document/TextBlock/index.tsx | 21 +- .../document/_shared/SubscribeNode.hooks.ts | 70 ++++++- .../{useTextEvents.ts => TextEvents.hooks.ts} | 14 +- .../document/_shared/Text/TextInput.hooks.ts | 193 ++++++------------ .../_shared/Text/TextSelection.hooks.ts | 113 ++++++++++ .../src/appflowy_app/interfaces/document.ts | 4 +- .../async-actions/blocks/text/indent.ts | 40 ++-- .../async-actions/blocks/text/merge.ts | 5 +- .../async-actions/blocks/text/outdent.ts | 52 ++++- .../async-actions/blocks/text/split.ts | 10 +- .../async-actions/blocks/text/update.ts | 45 ++-- .../reducers/document/async-actions/cursor.ts | 5 +- .../reducers/document/async-actions/index.ts | 12 ++ .../document/async-actions/range_selection.ts | 86 ++++++++ .../stores/reducers/document/slice.ts | 68 +++--- .../utils/document/blocks/common.ts | 35 +++- .../utils/document/blocks/text/delta.ts | 13 ++ .../appflowy_app/utils/document/subscribe.ts | 32 +-- .../src/appflowy_app/utils/tool.ts | 8 +- .../appflowy_tauri/src/styles/template.css | 5 + 30 files changed, 641 insertions(+), 380 deletions(-) rename frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/{useTextEvents.ts => TextEvents.hooks.ts} (88%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts index faaf40ea99..5510f7a77f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts @@ -24,6 +24,7 @@ export function useHoveringToolbar(id: string) { el.style.left = position.left; } }); + return { ref, inFocus, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts index a4ac5e75f9..bd7fa810fb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { useAppSelector } from '$app/stores/store'; import { RegionGrid } from '$app/utils/region_grid'; @@ -6,9 +6,7 @@ import { RegionGrid } from '$app/utils/region_grid'; export function useNodesRect(container: HTMLDivElement) { const controller = useContext(DocumentControllerContext); - const data = useAppSelector((state) => { - return state.document; - }); + const version = useVersionUpdate(); const regionGrid = useMemo(() => { if (!controller) return null; @@ -40,7 +38,7 @@ export function useNodesRect(container: HTMLDivElement) { // update nodes rect when data changed useEffect(() => { updateViewPortNodesRect(); - }, [data, updateViewPortNodesRect]); + }, [version, updateViewPortNodesRect]); // update nodes rect when scroll useEffect(() => { @@ -74,3 +72,19 @@ export function useNodesRect(container: HTMLDivElement) { getIntersectedBlockIds, }; } + +function useVersionUpdate() { + const [version, setVersion] = useState(0); + const data = useAppSelector((state) => { + return state.document; + }); + + useEffect(() => { + setVersion((v) => { + if (v < Number.MAX_VALUE) return v + 1; + return 0; + }); + }, [data]); + + return version; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx index e4c3b68089..72b805a7ba 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx @@ -1,57 +1,50 @@ -import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document'; -import { useAppSelector } from '@/appflowy_app/stores/store'; -import { debounce } from '@/appflowy_app/utils/tool'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BlockType, HeadingBlockData, NestedBlock } from "@/appflowy_app/interfaces/document"; +import { useAppDispatch } from "@/appflowy_app/stores/store"; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getBlockByIdThunk } from "$app_reducers/document/async-actions"; +const headingBlockTopOffset: Record = { + 1: 7, + 2: 6, + 3: 3, +}; export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) { - const [nodeId, setHoverNodeId] = useState(''); + const [nodeId, setHoverNodeId] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const ref = useRef(null); - const nodes = useAppSelector((state) => state.document.nodes); - const nodesRef = useRef(nodes); - - const handleMouseMove = useCallback((e: MouseEvent) => { - const { clientX, clientY } = e; - const x = clientX; - const y = clientY; - const id = getNodeIdByPoint(x, y); - if (!id) { - setHoverNodeId(''); - } else { - if ([BlockType.ColumnBlock].includes(nodesRef.current[id].type)) { - setHoverNodeId(''); - return; - } - setHoverNodeId(id); - } - }, []); - - const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]); + const dispatch = useAppDispatch(); + const [style, setStyle] = useState({}); useEffect(() => { const el = ref.current; if (!el || !nodeId) return; + void(async () => { + const{ payload: node } = await dispatch(getBlockByIdThunk(nodeId)) as { + payload: NestedBlock; + }; + if (!node) { + setStyle({ + opacity: '0', + pointerEvents: 'none', + }); + return; + } else { + let top = 1; - const node = nodesRef.current[nodeId]; - if (!node) { - el.style.opacity = '0'; - el.style.pointerEvents = 'none'; - } else { - el.style.opacity = '1'; - el.style.pointerEvents = 'auto'; - el.style.top = '1px'; - if (node?.type === BlockType.HeadingBlock) { - const nodeData = node.data as HeadingBlockData; - if (nodeData.level === 1) { - el.style.top = '8px'; - } else if (nodeData.level === 2) { - el.style.top = '6px'; - } else { - el.style.top = '5px'; + if (node.type === BlockType.HeadingBlock) { + const nodeData = node.data as HeadingBlockData; + top = headingBlockTopOffset[nodeData.level]; } + + setStyle({ + opacity: '1', + pointerEvents: 'auto', + top: `${top}px`, + }); } - } - }, [nodeId]); + })(); + + }, [dispatch, nodeId]); const handleToggleMenu = useCallback((isOpen: boolean) => { setMenuOpen(isOpen); @@ -60,22 +53,25 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement } } }, []); - useEffect(() => { - container.addEventListener('mousemove', debounceMove); - return () => { - container.removeEventListener('mousemove', debounceMove); - }; - }, [debounceMove]); + const handleMouseMove = useCallback((e: MouseEvent) => { + const { clientX, clientY } = e; + const id = getNodeIdByPoint(clientX, clientY); + setHoverNodeId(id); + }, []); useEffect(() => { - nodesRef.current = nodes; - }, [nodes]); + container.addEventListener('mousemove', handleMouseMove); + return () => { + container.removeEventListener('mousemove', handleMouseMove); + }; + }, [container, handleMouseMove]); return { nodeId, ref, handleToggleMenu, menuOpen, + style }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index 455c65b338..589c5e8cb9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx @@ -9,7 +9,7 @@ import BlockMenu from '../BlockMenu'; const sx = { height: 24, width: 24 }; export default function BlockSideToolbar(props: { container: HTMLDivElement }) { - const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props); + const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props); if (!nodeId) return null; return ( @@ -19,6 +19,7 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) { ref={ref} style={{ opacity: 0, + ...style, }} className='absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500' onMouseDown={(e) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts index 33e5ec4921..1d5a5cbf26 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts @@ -7,7 +7,7 @@ import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; import { useAppDispatch } from '$app/stores/store'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { splitNodeThunk } from '$app_reducers/document/async-actions'; -import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents'; +import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks'; import { indent, outdent } from '$app/utils/document/blocks/code'; export function useCodeBlock(node: NestedBlock) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx index 1942f72a70..7ad1d1a36a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx @@ -1,16 +1,13 @@ import React from 'react'; import { useDocumentTitle } from './DocumentTitle.hooks'; import TextBlock from '../TextBlock'; -import { NodeContext } from '../_shared/SubscribeNode.hooks'; export default function DocumentTitle({ id }: { id: string }) { const { node } = useDocumentTitle(id); if (!node) return null; 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 1cf896e9ad..d972c75183 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 @@ -3,7 +3,6 @@ import { useNode } from './Node.hooks'; import { withErrorBoundary } from 'react-error-boundary'; import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent'; import TextBlock from '../TextBlock'; -import { NodeContext } from '../_shared/SubscribeNode.hooks'; import { BlockType } from '$app/interfaces/document'; import { Alert } from '@mui/material'; @@ -59,15 +58,13 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes -
- {renderBlock()} -
- {isSelected ? ( -
- ) : null} -
- +
+ {renderBlock()} +
+ {isSelected ? ( +
+ ) : null} +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx index aa5dcd1efa..60aa7ecb77 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx @@ -1,19 +1,20 @@ import { BaseText } from 'slate'; import { RenderLeafProps } from 'slate-react'; - -const Leaf = ({ - attributes, - children, - leaf, -}: RenderLeafProps & { +interface LeafProps extends RenderLeafProps { leaf: BaseText & { bold?: boolean; code?: boolean; italic?: boolean; underlined?: boolean; strikethrough?: boolean; + selectionHighlighted?: boolean; }; -}) => { +} +const Leaf = ({ + attributes, + children, + leaf, +}: LeafProps) => { let newChildren = children; if (leaf.bold) { newChildren = {children}; @@ -31,8 +32,16 @@ const Leaf = ({ newChildren = {newChildren}; } + let className = ""; + if (leaf.strikethrough) { + className += "line-through"; + } + if (leaf.selectionHighlighted) { + className += " bg-main-secondary"; + } + return ( - + {newChildren} ); 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 01552bc85e..1864fcb929 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 @@ -2,13 +2,13 @@ import { useTextInput } from '../_shared/Text/TextInput.hooks'; import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks'; export function useTextBlock(id: string) { - const { editor, ...rest } = - useTextInput(id); + const { editor, ...props } = useTextInput(id); + const { onKeyDown } = useTextBlockKeyEvent(id, editor); return { onKeyDown, editor, - ...rest + ...props, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts index bc0845c4be..1b6ea72606 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts @@ -8,9 +8,10 @@ import isHotkey from 'is-hotkey'; import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { useAppDispatch } from '$app/stores/store'; -import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents'; +import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks'; +import { ReactEditor } from 'slate-react'; -export function useTextBlockKeyEvent(id: string, editor: Editor) { +export function useTextBlockKeyEvent(id: string, editor: ReactEditor) { const controller = useContext(DocumentControllerContext); const dispatch = useAppDispatch(); 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 b3c7e8e0af..880e7f629f 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,33 +2,28 @@ import { Slate, Editable } from 'slate-react'; import Leaf from './Leaf'; import { useTextBlock } from './TextBlock.hooks'; import BlockHorizontalToolbar from '../BlockHorizontalToolbar'; -import React from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; +import React, { useEffect } from 'react'; +import { NestedBlock } from '$app/interfaces/document'; import NodeChildren from '$app/components/document/Node/NodeChildren'; function TextBlock({ node, childIds, placeholder, - ...props + className = '', }: { node: NestedBlock; childIds?: string[]; placeholder?: string; -} & React.HTMLAttributes) { - const { - editor, - value, - onChange, - ...rest - } = useTextBlock(node.id); - const className = props.className !== undefined ? ` ${props.className}` : ''; + className?: string; +}) { + const { editor, value, onChange, ...rest } = useTextBlock(node.id); return ( <> -
+
- + {/**/} } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts index 476061eab0..c73b1315c9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts @@ -1,12 +1,11 @@ import { useAppSelector } from '@/appflowy_app/stores/store'; -import { useMemo, createContext } from 'react'; -import { Node } from '$app/interfaces/document'; -export const NodeContext = createContext(null); +import { useMemo, useRef } from 'react'; +import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document'; +import { nodeInRange } from '$app/utils/document/blocks/common'; +import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta'; /** - * Subscribe to a node and its children - * It will be change when the node or its children is changed - * And it will not be change when other node is changed + * Subscribe node information * @param id */ export function useSubscribeNode(id: string) { @@ -19,16 +18,13 @@ export function useSubscribeNode(id: string) { }); const isSelected = useAppSelector((state) => { - return state.rectSelection.selections?.includes(id) || false; + return state.documentRectSelection.includes(id) || false; }); // Memoize the node and its children // So that the component will not be re-rendered when other node is changed // It very important for performance - const memoizedNode = useMemo( - () => node, - [JSON.stringify(node)] - ); + const memoizedNode = useMemo(() => node, [JSON.stringify(node)]); const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]); return { @@ -37,3 +33,55 @@ export function useSubscribeNode(id: string) { isSelected, }; } + +/** + * Subscribe selection information + * @param id + */ +export function useSubscribeRangeSelection(id: string) { + const rangeRef = useRef(); + + const currentSelection = useAppSelector((state) => { + const range = state.documentRangeSelection; + rangeRef.current = range; + if (range.anchor?.id === id) { + return range.anchor.selection; + } + if (range.focus?.id === id) { + return range.focus.selection; + } + return getAmendInRangeNodeSelection(id, range, state.document); + }); + + return { + rangeRef, + currentSelection, + }; +} + +function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) { + if (!range.anchor || !range.focus || range.anchor.id === range.focus.id) { + return null; + } + const isForward = selectionIsForward(range.anchor.selection); + const isNodeInRange = nodeInRange( + id, + { + startId: range.anchor.id, + endId: range.focus.id, + }, + isForward, + document + ); + + if (isNodeInRange) { + const delta = document.nodes[id].data.delta; + return { + anchor: { + path: [0, 0], + offset: 0, + }, + focus: getNodeEndSelection(delta).anchor, + }; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/useTextEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts similarity index 88% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/useTextEvents.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts index 222545ec08..d4af6150e2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/useTextEvents.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts @@ -1,7 +1,6 @@ import { useAppDispatch } from '$app/stores/store'; import { useCallback, useContext } from 'react'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { Editor } from 'slate'; import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions'; import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; import { @@ -12,20 +11,21 @@ import { canHandleUpKey, } from '$app/utils/document/blocks/text/hotkey'; import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; +import { ReactEditor } from "slate-react"; export function useDefaultTextInputEvents(id: string) { const dispatch = useAppDispatch(); const controller = useContext(DocumentControllerContext); const focusPreLineAction = useCallback( - async (params: { editor: Editor; focusEnd?: boolean }) => { + async (params: { editor: ReactEditor; focusEnd?: boolean }) => { await dispatch(setCursorPreLineThunk({ id, ...params })); }, [dispatch, id] ); const focusNextLineAction = useCallback( - async (params: { editor: Editor; focusStart?: boolean }) => { + async (params: { editor: ReactEditor; focusStart?: boolean }) => { await dispatch(setCursorNextLineThunk({ id, ...params })); }, [dispatch, id] @@ -35,6 +35,8 @@ export function useDefaultTextInputEvents(id: string) { triggerEventKey: keyBoardEventKeyMap.Up, canHandle: canHandleUpKey, handler: (...args: TextBlockKeyEventHandlerParams) => { + const [e, _] = args; + e.preventDefault(); void focusPreLineAction({ editor: args[1], }); @@ -44,6 +46,8 @@ export function useDefaultTextInputEvents(id: string) { triggerEventKey: keyBoardEventKeyMap.Down, canHandle: canHandleDownKey, handler: (...args: TextBlockKeyEventHandlerParams) => { + const [e, _] = args; + e.preventDefault(); void focusNextLineAction({ editor: args[1], }); @@ -53,6 +57,8 @@ export function useDefaultTextInputEvents(id: string) { triggerEventKey: keyBoardEventKeyMap.Left, canHandle: canHandleLeftKey, handler: (...args: TextBlockKeyEventHandlerParams) => { + const [e, _] = args; + e.preventDefault(); void focusPreLineAction({ editor: args[1], focusEnd: true, @@ -63,6 +69,8 @@ export function useDefaultTextInputEvents(id: string) { triggerEventKey: keyBoardEventKeyMap.Right, canHandle: canHandleRightKey, handler: (...args: TextBlockKeyEventHandlerParams) => { + const [e, _] = args; + e.preventDefault(); void focusNextLineAction({ editor: args[1], focusStart: true, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts index 8194fbe51f..ae41fe8bdb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts @@ -1,22 +1,22 @@ -import { createEditor, Descendant, Transforms, Element, Text, Editor } from 'slate'; -import { ReactEditor, withReact } from 'slate-react'; +import { createEditor, Descendant, Editor } from 'slate'; +import { withReact } from 'slate-react'; import { useCallback, useContext, useEffect, useMemo, useRef, 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 '$app/stores/store'; +import { TextDelta } from '$app/interfaces/document'; +import { useAppDispatch } from '$app/stores/store'; import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update'; -import { deltaToSlateValue, getCollapsedRange, slateValueToDelta } from "$app/utils/document/blocks/common"; -import { rangeSelectionActions } from "$app_reducers/document/slice"; -import { getNodeEndSelection, isSameDelta } from '$app/utils/document/blocks/text/delta'; +import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common'; +import { isSameDelta } from '$app/utils/document/blocks/text/delta'; +import { debounce } from '$app/utils/tool'; +import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; +import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks'; export function useTextInput(id: string) { + const { node } = useSubscribeNode(id); const [editor] = useState(() => withReact(createEditor())); - const node = useContext(NodeContext); - const { sendDelta } = useController(id); - const { storeSelection } = useSelection(id, editor); const isComposition = useRef(false); + const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor); const delta = useMemo(() => { if (!node || !('delta' in node.data)) { @@ -24,38 +24,30 @@ export function useTextInput(id: string) { } return node.data.delta; }, [node]); + + const { sync, receive } = useUpdateDelta(id, editor); + const [value, setValue] = useState(deltaToSlateValue(delta)); // Update the editor's value when the node's delta changes. useEffect(() => { // If composition is in progress, do nothing. if (isComposition.current) return; - - // If the delta is the same as the editor's value, do nothing. - const localDelta = slateValueToDelta(editor.children); - const isSame = isSameDelta(delta, localDelta); - if (isSame) return; - - const slateValue = deltaToSlateValue(delta); - editor.children = slateValue; - setValue(slateValue); - }, [delta, editor]); + receive(delta); + }, [delta, receive]); // Update the node's delta when the editor's value changes. const onChange = useCallback( (e: Descendant[]) => { // Update the editor's value and selection. setValue(e); - storeSelection(); - + // If the selection is not null, update the last active selection. + if (editor.selection !== null) setLastActiveSelection(editor.selection); // If composition is in progress, do nothing. if (isComposition.current) return; - - // Update the node's delta - const textDelta = slateValueToDelta(e); - void sendDelta(textDelta); + sync(); }, - [sendDelta, storeSelection] + [editor.selection, setLastActiveSelection, sync] ); const onDOMBeforeInput = useCallback((e: InputEvent) => { @@ -83,6 +75,7 @@ export function useTextInput(id: string) { editor, onChange, value, + ...selectionProps, onDOMBeforeInput, onCompositionStart, onCompositionUpdate, @@ -90,118 +83,60 @@ export function useTextInput(id: string) { }; } -function useController(id: string) { - const docController = useContext(DocumentControllerContext); +function useUpdateDelta(id: string, editor: Editor) { + const controller = useContext(DocumentControllerContext); const dispatch = useAppDispatch(); + const penddingRef = useRef(false); - const sendDelta = useCallback( - async (delta: TextDelta[]) => { - if (!docController) return; - await dispatch( - updateNodeDeltaThunk({ - id, - delta, - controller: docController, - }) - ); - }, - [dispatch, docController, id] - ); + // when user input, update the node's delta after 200ms + const debounceUpdate = useMemo(() => { + return debounce(() => { + if (!controller) return; + const delta = slateValueToDelta(editor.children); + void (async () => { + await dispatch( + updateNodeDeltaThunk({ + id, + delta, + controller, + }) + ); + // reset pendding flag + penddingRef.current = false; + })(); + }, 200); + }, [controller, dispatch, editor, id]); - return { - sendDelta, - }; -} + const sync = useCallback(() => { + // set pendding flag + penddingRef.current = true; + debounceUpdate(); + }, [debounceUpdate]); -function useSelection(id: string, editor: ReactEditor) { - const dispatch = useAppDispatch(); - const selectionRef = useRef(null); - const currentSelection = useAppSelector((state) => { - const range = state.rangeSelection; - if (!range.anchor || !range.focus) return null; - if (range.anchor.id === id) { - return range.anchor.selection; - } - if (range.focus.id === id) { - return range.focus.selection; - } - return null; - }); + const receive = useCallback( + (delta: TextDelta[]) => { + // if pendding, do nothing + if (penddingRef.current) return; - // whether the selection is out of range. - const outOfRange = useCallback( - (selection: TextSelection) => { - const point = Editor.end(editor, selection); - const { path, offset } = point; - // path length is 2, because the editor is a single text node. - const [i, j] = path; - const children = editor.children[i] as Element; - if (!children) return true; - const child = children.children[j] as Text; - return child.text.length < offset; + // If the delta is the same as the editor's value, do nothing. + const localDelta = slateValueToDelta(editor.children); + const isSame = isSameDelta(delta, localDelta); + if (isSame) return; + + const slateValue = deltaToSlateValue(delta); + editor.children = slateValue; }, [editor] ); - // store the selection - const storeSelection = useCallback(() => { - // do nothing if the node is not focused. - if (!ReactEditor.isFocused(editor)) { - selectionRef.current = null; - return; - } - // set selection to the end of the node if the selection is out of range. - if (outOfRange(editor.selection as TextSelection)) { - editor.selection = getNodeEndSelection(slateValueToDelta(editor.children)); - selectionRef.current = null; - } - - let selection = editor.selection as TextSelection; - // the selection will sometimes be cleared after the editor is focused. - // so we need to restore the selection when selection ref is not null. - if (selectionRef.current && JSON.stringify(editor.selection) !== JSON.stringify(selectionRef.current)) { - Transforms.select(editor, selectionRef.current); - selection = selectionRef.current; - } - selectionRef.current = null; - const range = getCollapsedRange(id, selection); - dispatch(rangeSelectionActions.setRange(range)); - }, [dispatch, editor, id, outOfRange]); - - - // restore the selection - const restoreSelection = useCallback((selection: TextSelection | null) => { - if (!selection) return; - // do nothing if the selection is out of range - if (outOfRange(selection)) return; - - if (ReactEditor.isFocused(editor)) { - // if the editor is focused, set the selection directly. - if (JSON.stringify(selection) === JSON.stringify(editor.selection)) return; - Transforms.select(editor, selection); - } else { - // Here we store the selection in the ref, - // because the selection will sometimes be cleared after the editor is focused. - selectionRef.current = selection; - Transforms.select(editor, selection); - ReactEditor.focus(editor); - } - }, [editor, outOfRange]); - useEffect(() => { - restoreSelection(currentSelection); - }, [restoreSelection, currentSelection]); - - 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 - if (domSelection?.rangeCount === 0) { - const range = ReactEditor.toDOMRange(editor, editor.selection); - domSelection.addRange(range); - } - } + return () => { + debounceUpdate.cancel(); + }; + }); return { - storeSelection, + sync, + receive, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts new file mode 100644 index 0000000000..b98e891747 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts @@ -0,0 +1,113 @@ +import { MouseEventHandler, useCallback, useEffect } from 'react'; +import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate'; +import { EditableProps } from 'slate-react/dist/components/editable'; +import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks'; +import { useAppDispatch } from '$app/stores/store'; +import { rangeSelectionActions } from '$app_reducers/document/slice'; +import { TextSelection } from '$app/interfaces/document'; +import { ReactEditor } from 'slate-react'; +import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection'; +import { getCollapsedRange } from '$app/utils/document/blocks/common'; +import { getEditorEndPoint, selectionIsForward } from '$app/utils/document/blocks/text/delta'; + +export function useTextSelections(id: string, editor: ReactEditor) { + const { rangeRef, currentSelection } = useSubscribeRangeSelection(id); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!rangeRef.current) return; + const { isDragging, focus, anchor } = rangeRef.current; + if (isDragging || anchor?.id !== focus?.id || !currentSelection || !Range.isCollapsed(currentSelection as BaseRange)) + return; + + if (!ReactEditor.isFocused(editor)) { + ReactEditor.focus(editor); + } + Transforms.select(editor, currentSelection); + }, [currentSelection, editor, rangeRef]); + + const decorate: EditableProps['decorate'] = useCallback( + (entry: [Node, Path]) => { + const [node, path] = entry; + + if (currentSelection && !Range.isCollapsed(currentSelection as BaseRange)) { + const intersection = Range.intersection(currentSelection, Editor.range(editor, path)); + + if (!intersection) { + return []; + } + const range = { + selectionHighlighted: true, + ...intersection, + }; + + return [range]; + } + return []; + }, + [editor, currentSelection] + ); + + const onMouseDown: MouseEventHandler = useCallback( + (e) => { + const range = getCollapsedRange(id, editor.selection as TextSelection); + dispatch( + rangeSelectionActions.setRange({ + ...range, + isDragging: true, + }) + ); + }, + [dispatch, editor, id] + ); + + const onMouseMove: MouseEventHandler = useCallback( + (e) => { + if (!rangeRef.current) return; + const { isDragging, anchor } = rangeRef.current; + if (!isDragging || !anchor || ReactEditor.isFocused(editor)) return; + + const isForward = selectionIsForward(anchor.selection); + if (!isForward) { + Transforms.select(editor, getEditorEndPoint(editor)); + } + ReactEditor.focus(editor); + }, + [editor, rangeRef] + ); + + const onMouseUp: MouseEventHandler = useCallback( + (e) => { + if (!rangeRef.current) return; + const { isDragging } = rangeRef.current; + if (!isDragging) return; + dispatch( + rangeSelectionActions.setRange({ + isDragging: false, + }) + ); + }, + [dispatch, rangeRef] + ); + + const setLastActiveSelection = useCallback( + (lastActiveSelection: Range) => { + const selection = lastActiveSelection as TextSelection; + dispatch(syncRangeSelectionThunk({ id, selection })); + }, + [dispatch, id] + ); + + const onBlur = useCallback(() => { + ReactEditor.deselect(editor); + }, [editor]); + + return { + decorate, + onMouseDown, + onMouseMove, + onMouseUp, + onBlur, + setLastActiveSelection, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 0085fd045d..9f15a21213 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -1,5 +1,6 @@ import { Editor } from 'slate'; import { RegionGrid } from '$app/utils/region_grid'; +import { ReactEditor } from "slate-react"; export enum BlockType { PageBlock = 'page', @@ -131,6 +132,7 @@ export interface DocumentState { } export interface RangeSelectionState { + isDragging?: boolean, anchor?: PointState, focus?: PointState, } @@ -158,4 +160,4 @@ export interface BlockPBValue { data: string; } -export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent, Editor]; +export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent, ReactEditor & Editor]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts index b4bc905b41..2e8e46bd35 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts @@ -2,7 +2,15 @@ import { DocumentState } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { blockConfig } from '$app/constants/document/config'; +import { getPrevNodeId } from "$app/utils/document/blocks/common"; +/** + * indent node + * 1. if node parent is root, do nothing + * 2. if node parent is not root + * 2.1. get prev node, if prev node is not allowed to have children, do nothing + * 2.2. if prev node is allowed to have children, move node to prev node's last child, and move node's children after node + */ export const indentNodeThunk = createAsyncThunk( 'document/indentNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { @@ -11,21 +19,23 @@ export const indentNodeThunk = createAsyncThunk( const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; if (!node.parent) return; - // get parent - const parent = state.nodes[node.parent]; - // get prev node - const children = state.children[parent.children]; - const index = children.indexOf(id); - if (index === 0) return; - const newParentId = children[index - 1]; - const prevNode = state.nodes[newParentId]; - // check if prev node is allowed to have children - const config = blockConfig[prevNode.type]; - if (!config.canAddChild) 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]; - await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); + // get prev node + const prevNodeId = getPrevNodeId(state, id); + if (!prevNodeId) return; + const newParentNode = state.nodes[prevNodeId]; + // check if prev node is allowed to have children + const config = blockConfig[newParentNode.type]; + if (!config.canAddChild) return; + + // check if prev node has children and get last child for new prev node + const newParentChildren = state.children[newParentNode.children]; + const newPrevId = newParentChildren[newParentChildren.length - 1]; + + const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId); + const childrenNodes = state.children[node.children].map(id => state.nodes[id]); + const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id); + + await controller.applyActions([moveAction, ...moveChildrenActions]); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts index 506dd1424f..39e8842dec 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts @@ -2,7 +2,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentState } from '$app/interfaces/document'; import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common"; -import { documentActions, rangeSelectionActions } from "$app_reducers/document/slice"; +import { rangeSelectionActions } from "$app_reducers/document/slice"; import { blockConfig } from '$app/constants/document/config'; import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta'; @@ -40,8 +40,6 @@ export const mergeToPrevLineThunk = createAsyncThunk( const mergeDelta = [...prevLineDelta, ...node.data.delta]; - dispatch(documentActions.updateNodeData({ id: prevLine.id, data: { delta: mergeDelta } })); - const updateAction = controller.getUpdateAction({ ...prevLine, data: { @@ -66,7 +64,6 @@ export const mergeToPrevLineThunk = createAsyncThunk( actions.push(deleteAction); } else { // clear current block delta - dispatch(documentActions.updateNodeData({ id: node.id, data: { delta: [] } })); const updateAction = controller.getUpdateAction({ ...node, data: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts index f0f5fc0bbe..12ea7672e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts @@ -1,8 +1,17 @@ import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; - import { DocumentState } from '$app/interfaces/document'; +import { blockConfig } from '$app/constants/document/config'; +/** + * outdent node + * 1. if node parent is root, do nothing + * 2. if node parent is not root, move node to after parent and record next sibling ids + * 2.1. if next sibling ids is empty, do nothing + * 2.2. if next sibling ids is not empty + * 2.2.1. if node can add child, move next sibling ids to node's children + * 2.2.2. if node can not add child, move next sibling ids to after node + */ export const outdentNodeThunk = createAsyncThunk( 'document/outdentNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { @@ -10,11 +19,40 @@ export const outdentNodeThunk = createAsyncThunk( const { getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; - const newPrevId = node.parent; - if (!newPrevId) return; - const parent = state.nodes[newPrevId]; - const newParentId = parent.parent; - if (!newParentId) return; - await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); + const parentId = node.parent; + if (!parentId) return; + const ancestorId = state.nodes[parentId].parent; + if (!ancestorId) return; + + const parent = state.nodes[parentId]; + const index = state.children[parent.children].indexOf(id); + const nextSiblingIds = state.children[parent.children].slice(index + 1); + + const actions = []; + const moveAction = controller.getMoveAction(node, ancestorId, parentId); + actions.push(moveAction); + + const config = blockConfig[node.type]; + if (nextSiblingIds.length > 0) { + if (config.canAddChild) { + const children = state.children[node.children]; + let lastChildId: string | null = null; + const lastIndex = children.length - 1; + if (lastIndex >= 0) { + lastChildId = children[lastIndex]; + } + const moveChildrenActions = nextSiblingIds + .reverse() + .map((id) => controller.getMoveAction(state.nodes[id], node.id, lastChildId)); + actions.push(...moveChildrenActions); + } else { + const moveChildrenActions = nextSiblingIds + .reverse() + .map((id) => controller.getMoveAction(state.nodes[id], ancestorId, node.id)); + actions.push(...moveChildrenActions); + } + } + + await controller.applyActions(actions); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts index 2421981434..534cf6b24d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts @@ -1,16 +1,15 @@ -import { DocumentState, TextDelta } from '$app/interfaces/document'; +import { DocumentState } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions } from '$app_reducers/document/slice'; import { setCursorBeforeThunk } from '../../cursor'; import { newBlock } from '$app/utils/document/blocks/common'; import { blockConfig, SplitRelationship } from '$app/constants/document/config'; -import { Editor } from 'slate'; import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta'; +import { ReactEditor } from "slate-react"; export const splitNodeThunk = createAsyncThunk( 'document/splitNode', - async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => { + async (payload: { id: string; editor: ReactEditor; controller: DocumentController }, thunkAPI) => { const { id, controller, editor } = payload; // get the split content const { retain, insert } = getSplitDelta(editor); @@ -68,8 +67,7 @@ export const splitNodeThunk = createAsyncThunk( await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]); - // update local node data - dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } })); + ReactEditor.deselect(editor); // set cursor await dispatch(setCursorBeforeThunk({ id: newNode.id })); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts index 20ec3c33da..9248f15667 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts @@ -1,44 +1,29 @@ -import { TextDelta, NestedBlock, DocumentState, BlockData } from '$app/interfaces/document'; +import { TextDelta, DocumentState, BlockData } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions } from '$app_reducers/document/slice'; -import { debounce } from '$app/utils/tool'; import { isSameDelta } from '$app/utils/document/blocks/text/delta'; + export const updateNodeDeltaThunk = createAsyncThunk( 'document/updateNodeDelta', async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => { const { id, delta, controller } = payload; - const { dispatch, getState } = thunkAPI; + const { getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; - const isSame = isSameDelta(delta, node.data.delta); - if (isSame) return; - // The block map should be updated immediately - // or the component will use the old data to update the editor - dispatch(documentActions.updateNodeData({ id, data: { delta } })); + const isSame = isSameDelta(delta, node.data.delta || []); - // the transaction is delayed to avoid too many updates - debounceApplyUpdate(controller, { - ...node, - data: { - ...node.data, - delta, - }, - }); + if (isSame) return; + const newData = { ...node.data, delta }; + + await controller.applyActions([ + controller.getUpdateAction({ + ...node, + data: newData, + }), + ]); } ); -const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: NestedBlock) => { - void controller.applyActions([ - controller.getUpdateAction({ - ...updateNode, - data: { - ...updateNode.data, - }, - }), - ]); -}, 500); - export const updateNodeDataThunk = createAsyncThunk< void, { @@ -48,14 +33,12 @@ export const updateNodeDataThunk = createAsyncThunk< } >('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => { const { id, data, controller } = payload; - const { dispatch, getState } = thunkAPI; + const { getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; const newData = { ...node.data, ...data }; - dispatch(documentActions.updateNodeData({ id, data: newData })); - await controller.applyActions([ controller.getUpdateAction({ ...node, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts index 571564c286..9645df28f8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts @@ -11,6 +11,7 @@ import { getStartLineSelectionByOffset, } from '$app/utils/document/blocks/text/delta'; import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common"; +import { ReactEditor } from "slate-react"; export const setCursorBeforeThunk = createAsyncThunk( 'document/setCursorBefore', @@ -39,7 +40,7 @@ export const setCursorAfterThunk = createAsyncThunk( export const setCursorPreLineThunk = createAsyncThunk( 'document/setCursorPreLine', - async (payload: { id: string; editor: Editor; focusEnd?: boolean }, thunkAPI) => { + async (payload: { id: string; editor: ReactEditor; focusEnd?: boolean }, thunkAPI) => { const { id, editor, focusEnd } = payload; const selection = editor.selection as TextSelection; const { dispatch, getState } = thunkAPI; @@ -73,7 +74,7 @@ export const setCursorPreLineThunk = createAsyncThunk( export const setCursorNextLineThunk = createAsyncThunk( 'document/setCursorNextLine', - async (payload: { id: string; editor: Editor; focusStart?: boolean }, thunkAPI) => { + async (payload: { id: string; editor: ReactEditor; focusStart?: boolean }, thunkAPI) => { const { id, editor, focusStart } = payload; const selection = editor.selection as TextSelection; const { dispatch, getState } = thunkAPI; 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 index 2b82dfe792..721c18513f 100644 --- 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 @@ -1,3 +1,15 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { DocumentState, NestedBlock } from "$app/interfaces/document"; + export * from './cursor'; export * from './blocks'; export * from './turn_to'; + +export const getBlockByIdThunk = createAsyncThunk( + 'document/getBlockById', + async (id, thunkAPI) => { + const { getState } = thunkAPI; + const state = getState() as { document: DocumentState }; + const node = state.document.nodes[id] as NestedBlock; + return node; + }); \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts new file mode 100644 index 0000000000..996fccafe8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts @@ -0,0 +1,86 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document'; +import { rangeSelectionActions } from '$app_reducers/document/slice'; +import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta'; +import { isEqual } from '$app/utils/tool'; + +const amendAnchorNodeThunk = createAsyncThunk( + 'document/amendAnchorNode', + async ( + payload: { + id: string; + }, + thunkAPI + ) => { + const { id } = payload; + const { getState, dispatch } = thunkAPI; + const nodes = (getState() as { document: DocumentState }).document.nodes; + const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection; + const { anchor: anchorNode, isDragging, focus: focusNode } = range; + + if (!isDragging || !anchorNode || anchorNode.id !== id) return; + const isCollapsed = focusNode?.id === id && anchorNode?.id === id; + if (isCollapsed) return; + + const selection = anchorNode.selection; + const isForward = selectionIsForward(selection); + const node = nodes[id]; + const focus = isForward + ? getNodeEndSelection(node.data.delta).anchor + : { + path: [0, 0], + offset: 0, + }; + if (isEqual(focus, selection.focus)) return; + const newSelection = { + anchor: selection.anchor, + focus, + }; + + dispatch( + rangeSelectionActions.setRange({ + anchor: { + id, + selection: newSelection as TextSelection, + }, + }) + ); + } +); + +export const syncRangeSelectionThunk = createAsyncThunk( + 'document/syncRangeSelection', + async ( + payload: { + id: string; + selection: TextSelection; + }, + thunkAPI + ) => { + const { getState, dispatch } = thunkAPI; + const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection; + + const { id, selection } = payload; + const updateRange = { + focus: { + id, + selection, + }, + }; + const isAnchor = range.anchor?.id === id; + if (isAnchor) { + Object.assign(updateRange, { + anchor: { + id, + selection, + }, + }); + } + dispatch(rangeSelectionActions.setRange(updateRange)); + + const anchorId = range.anchor?.id; + if (!isAnchor && anchorId) { + dispatch(amendAnchorNodeThunk({ id: anchorId })); + } + } +); 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 0f6c7c8eab..e91baf3869 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 @@ -1,26 +1,22 @@ import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document'; import { BlockEventPayloadPB } from '@/services/backend'; -import { combineReducers, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { parseValue, matchChange } from '$app/utils/document/subscribe'; -import blockSelection from "$app/components/document/BlockSelection"; -import { databaseSlice } from "$app_reducers/database/slice"; const initialState: DocumentState = { nodes: {}, children: {}, }; -const rectSelectionInitialState: { - selections: string[]; -} = { - selections: [], -}; +const rectSelectionInitialState: string[] = []; const rangeSelectionInitialState: RangeSelectionState = {}; export const documentSlice = createSlice({ name: 'document', initialState: initialState, + // Here we can't offer actions to update the document state. + // Because the document state is updated by the `onDataChange` reducers: { // initialize the document clear: () => { @@ -40,22 +36,13 @@ export const documentSlice = createSlice({ state.children = children; }, - // We need this action to update the local state before `onDataChange` to make the UI more smooth, - // because we often use `debounce` to send the change to db, so the db data will be updated later. - updateNodeData: (state, action: PayloadAction<{ id: string; data: Record }>) => { - const { id, data } = action.payload; - const node = state.nodes[id]; - if (!node) return; - node.data = { - ...node.data, - ...data, - }; - }, - - // when we use `onDataChange` to handle the change, we don't need care about the change is from which client, - // because the data is always from db state, and then to UI. - // Except the `updateNodeData` action, we will use it before `onDataChange` to update the local state, - // so we should skip update block's `data` field when the change is from local + /** + This function listens for changes in the data layer triggered by the data API, + and updates the UI state accordingly. + It enables a unidirectional data flow, + where changes in the data layer update the UI layer, + but not the other way around. + */ onDataChange: ( state, action: PayloadAction<{ @@ -64,52 +51,49 @@ export const documentSlice = createSlice({ }> ) => { const { path, id, value, command } = action.payload.data; - const isRemote = action.payload.isRemote; const valueJson = parseValue(value); if (!valueJson) return; // match change - matchChange(state, { path, id, value: valueJson, command }, isRemote); + matchChange(state, { path, id, value: valueJson, command }); }, }, }); export const rectSelectionSlice = createSlice({ - name: 'rectSelection', + name: 'documentRectSelection', initialState: rectSelectionInitialState, reducers: { // update block selections updateSelections: (state, action: PayloadAction) => { - state.selections = action.payload; + return action.payload; }, // set block selected setSelectionById: (state, action: PayloadAction) => { const id = action.payload; - state.selections = [id]; + if (state.includes(id)) return; + state.push(id); }, - } + }, }); - export const rangeSelectionSlice = createSlice({ - name: 'rangeSelection', + name: 'documentRangeSelection', initialState: rangeSelectionInitialState, reducers: { - setRange: ( - state, - action: PayloadAction - ) => { - state.anchor = action.payload.anchor; - state.focus = action.payload.focus; + setRange: (state, action: PayloadAction) => { + return { + ...state, + ...action.payload, + }; }, clearRange: (state, _: PayloadAction) => { - state.anchor = undefined; - state.focus = undefined; + return rangeSelectionInitialState; }, - } + }, }); export const documentReducers = { @@ -120,4 +104,4 @@ export const documentReducers = { export const documentActions = documentSlice.actions; export const rectSelectionActions = rectSelectionSlice.actions; -export const rangeSelectionActions = rangeSelectionSlice.actions; \ No newline at end of file +export const rangeSelectionActions = rangeSelectionSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts index 2ff3ca0d64..a53b109999 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts @@ -5,13 +5,13 @@ import { NestedBlock, RangeSelectionState, TextDelta, - TextSelection -} from "$app/interfaces/document"; + TextSelection, +} from '$app/interfaces/document'; import { Descendant, Element, Text } from 'slate'; import { BlockPB } from '@/services/backend'; import { Log } from '$app/utils/log'; import { nanoid } from 'nanoid'; -import { clone } from "$app/utils/tool"; +import { clone } from '$app/utils/tool'; export function slateValueToDelta(slateNodes: Descendant[]) { const element = slateNodes[0] as Element; @@ -145,10 +145,35 @@ export function newBlock(type: BlockType, parentId: string, data: BlockDat export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState { const point = { id, - selection + selection, }; return { anchor: clone(point), focus: clone(point), + isDragging: false, + }; +} + +export function nodeInRange( + id: string, + range: { + startId: string; + endId: string; + }, + isForward: boolean, + document: DocumentState +) { + const { startId, endId } = range; + let currentId = startId; + while (currentId && currentId !== id && currentId !== endId) { + if (isForward) { + currentId = getNextLineId(document, currentId) || ''; + } else { + currentId = getPrevLineId(document, currentId) || ''; + } } -} \ No newline at end of file + if (currentId === id) { + return true; + } + return false; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts index 204ea24457..e5c259e383 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts @@ -80,6 +80,13 @@ export function getNodeBeginSelection(): TextSelection { return selection; } +export function getEditorEndPoint(editor: Editor): SelectionPoint { + const fragment = (editor.children[0] as Element).children; + const lastIndex = fragment.length - 1; + const lastNode = fragment[lastIndex] as Text; + return { path: [0, lastIndex], offset: lastNode.text.length }; +} + /** * get the selection of the end of the node * @param delta @@ -282,3 +289,9 @@ export function getPointOfCurrentLineBeginning(editor: Editor) { const beginPoint = getPointByTextOffset(delta, lineBeginOffset); return beginPoint; } + +export function selectionIsForward(selection: TextSelection) { + const { anchor, focus } = selection; + if (!anchor || !focus) return false; + return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts index 1bd5bfc60a..64728a356e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts @@ -2,6 +2,7 @@ import { DeltaTypePB } from "@/services/backend/models/flowy-document2"; import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from "$app/interfaces/document"; import { Log } from "../log"; import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from "$app/constants/document/block"; +import { isEqual } from "$app/utils/tool"; // This is a list of all the possible changes that can happen to document data const matchCases = [ @@ -26,12 +27,11 @@ export function matchChange( id: string; value: BlockPBValue & string[]; }, - isRemote?: boolean ) { const matchCase = matchCases.find((item) => item.match(command, path)); if (matchCase) { - matchCase.onMatch(state, id, value, isRemote); + matchCase.onMatch(state, id, value); } } @@ -99,46 +99,34 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) { ); } -function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) { +function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue) { state.nodes[blockId] = blockChangeValue2Node(blockValue); } -function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) { +function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) { const block = blockChangeValue2Node(blockValue); const node = state.nodes[blockId]; if (!node) return; - // if the change is from remote, we should update all fields - if (isRemote) { - state.nodes[blockId] = block; - return; - } - // if the change is from local, we should update all fields except `data`, - // because we will update `data` field in `updateNodeData` action - const shouldUpdate = node.parent !== block.parent || node.type !== block.type || node.children !== block.children; - if (shouldUpdate) { - state.nodes[blockId] = { - ...block, - data: node.data, - }; - } + if (isEqual(node, block)) return; + state.nodes[blockId] = block; return; } -function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue, _isRemote?: boolean) { +function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue) { delete state.nodes[blockId]; } -function onMatchChildrenInsert(state: DocumentState, id: string, children: string[], _isRemote?: boolean) { +function onMatchChildrenInsert(state: DocumentState, id: string, children: string[]) { state.children[id] = children; } -function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[], _isRemote?: boolean) { +function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) { const children = state.children[id]; if (!children) return; state.children[id] = newChildren; } -function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[], _isRemote?: boolean) { +function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[]) { delete state.children[id]; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts index 74570454e2..55d40e9b9c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts @@ -1,11 +1,15 @@ export function debounce(fn: (...args: any[]) => void, delay: number) { let timeout: NodeJS.Timeout; - return (...args: any[]) => { + const debounceFn = (...args: any[]) => { clearTimeout(timeout); timeout = setTimeout(() => { fn.apply(undefined, args); }, delay); }; + debounceFn.cancel = () => { + clearTimeout(timeout); + }; + return debounceFn; } export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) { @@ -97,4 +101,4 @@ export function clone(value: T): T { result[key] = clone(value[key]); } return result; -} \ No newline at end of file +} diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css index 1c9c6191e7..d25f3384b2 100644 --- a/frontend/appflowy_tauri/src/styles/template.css +++ b/frontend/appflowy_tauri/src/styles/template.css @@ -20,6 +20,11 @@ body { @apply bg-[#E0F8FF] } +#appflowy-block-doc ::selection { + @apply bg-[transparent] +} + + .btn { @apply rounded-xl border border-gray-500 px-4 py-3; }