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 ad2dfcf69d..58526cae7c 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,6 +1,6 @@ import TextBlock from '../TextBlock'; import { Node } from '@/appflowy_app/stores/reducers/document/slice'; -import { TextDelta } from '@/appflowy_app/interfaces/document'; +import { HeadingBlockData } from '@/appflowy_app/interfaces/document'; const fontSize: Record = { 1: 'mt-8 text-3xl', @@ -8,9 +8,15 @@ const fontSize: Record = { 3: 'mt-4 text-xl', }; -export default function HeadingBlock({ node, delta }: { node: Node; delta: TextDelta[] }) { +export default function HeadingBlock({ + node, +}: { + node: Node & { + data: HeadingBlockData; + }; +}) { return ( -
+
{/**/}
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx index 82fd423e9d..2a24900eb2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx @@ -2,7 +2,15 @@ import React, { useMemo } from 'react'; import ColumnBlock from '../ColumnBlock'; import { Node } from '@/appflowy_app/stores/reducers/document/slice'; -export default function ColumnListBlock({ node, childIds }: { node: Node; childIds?: string[] }) { +export default function ColumnListBlock({ + node, + childIds, +}: { + node: Node & { + data: Record; + }; + childIds?: string[]; +}) { const resizerWidth = useMemo(() => { return 46 * (node.children?.length || 0); }, [node.children?.length]); @@ -13,7 +21,7 @@ export default function ColumnListBlock({ node, childIds }: { node: Node; childI ))} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx index 5d8671ac44..b156bfa55e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx @@ -6,27 +6,23 @@ import ColumnListBlock from './ColumnListBlock'; import { Node } from '@/appflowy_app/stores/reducers/document/slice'; import { TextDelta } from '@/appflowy_app/interfaces/document'; -export default function ListBlock({ node, delta }: { node: Node; delta: TextDelta[] }) { +export default function ListBlock({ node }: { node: Node }) { const title = useMemo(() => { - if (node.data.style?.type === 'column') return <>; - return ( -
- {/**/} -
- ); - }, [node, delta]); + // if (node.data.style?.type === 'column') return <>; + return
{/**/}
; + }, [node]); - if (node.data.style?.type === 'numbered') { - return ; - } - - if (node.data.style?.type === 'bulleted') { - return ; - } - - if (node.data.style?.type === 'column') { - return ; - } + // if (node.data.type === 'numbered') { + // return ; + // } + // + // if (node.data.type === 'bulleted') { + // return ; + // } + // + // if (node.data.type === 'column') { + // return ; + // } return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts index f586500186..ad4f7a2d56 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 { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey'; -import { useCallback, useContext, useState } from 'react'; -import { Descendant, Range, Editor, Element, Text, Location } from 'slate'; +import { useCallback, useContext } from 'react'; +import { Range, Editor, Element, Text, Location } from 'slate'; import { TextDelta } from '$app/interfaces/document'; import { useTextInput } from '../_shared/TextInput.hooks'; import { useAppDispatch } from '@/appflowy_app/stores/store'; @@ -10,65 +10,76 @@ import { indentNodeThunk, splitNodeThunk, } from '@/appflowy_app/stores/reducers/document/async_actions'; -import { TextSelection } from '@/appflowy_app/stores/reducers/document/slice'; +import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice'; -export function useTextBlock(id: string, delta: TextDelta[]) { - const { editor, onSelectionChange } = useTextInput(id, delta); - const [value, setValue] = useState([]); +export function useTextBlock(id: string) { + const { editor, onChange, value } = useTextInput(id); const { onTab, onBackSpace, onEnter } = useActions(id); - const onChange = useCallback( - (e: Descendant[]) => { - setValue(e); - editor.operations.forEach((op) => { - if (op.type === 'set_selection') { - onSelectionChange(op.newProperties as TextSelection); - } - }); - }, - [editor] - ); + const dispatch = useAppDispatch(); - const onKeyDownCapture = (event: React.KeyboardEvent) => { - switch (event.key) { - case 'Enter': { - if (!editor.selection) return; - event.stopPropagation(); - event.preventDefault(); - const retainRange = getRetainRangeBy(editor); - const retain = getDelta(editor, retainRange); - const insertRange = getInsertRangeBy(editor); - const insert = getDelta(editor, insertRange); - void (async () => { - await onEnter(retain, insert); - })(); - return; - } - case 'Backspace': { - if (!editor.selection) return; + 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 { anchor } = editor.selection; - const isCollapsed = Range.isCollapsed(editor.selection); - if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') { + const onKeyDownCapture = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + // It should be handled when `Enter` is pressed + case 'Enter': { + if (!editor.selection) return; event.stopPropagation(); event.preventDefault(); + // get the retain content + const retainRange = getRetainRangeBy(editor); + const retain = getDelta(editor, retainRange); + // get the insert content + const insertRange = getInsertRangeBy(editor); + const insert = getDelta(editor, insertRange); void (async () => { - await onBackSpace(); + // retain this node and insert a new node + await onEnter(retain, insert); })(); + return; } - return; - } - case 'Tab': { - event.stopPropagation(); - event.preventDefault(); - void (async () => { - await onTab(); - })(); + // It should be handled when `Backspace` is pressed + case 'Backspace': { + if (!editor.selection) { + return; + } + // It should be handled if the selection is collapsed and the cursor is at the beginning of the block + const { anchor } = editor.selection; + const isCollapsed = Range.isCollapsed(editor.selection); + if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') { + event.stopPropagation(); + event.preventDefault(); + keepSelection(); + void (async () => { + await onBackSpace(); + })(); + } + return; + } + // It should be handled when `Tab` is pressed + case 'Tab': { + event.stopPropagation(); + event.preventDefault(); + keepSelection(); + void (async () => { + await onTab(); + })(); - return; + return; + } } - } - triggerHotkey(event, editor); - }; + triggerHotkey(event, editor); + }, + [editor, keepSelection, onEnter, onBackSpace, onTab] + ); const onDOMBeforeInput = useCallback((e: InputEvent) => { // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition". 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 c838d2d9bf..4538391bd0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx @@ -4,7 +4,7 @@ import { useTextBlock } from './TextBlock.hooks'; import { Node } from '@/appflowy_app/stores/reducers/document/slice'; import NodeComponent from '../Node'; import HoveringToolbar from '../_shared/HoveringToolbar'; -import React, { useMemo } from 'react'; +import React, { useEffect } from 'react'; function TextBlock({ node, @@ -16,9 +16,7 @@ function TextBlock({ childIds?: string[]; placeholder?: string; } & React.HTMLAttributes) { - const delta = useMemo(() => node.data.delta || [], [node.data.delta]); - const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id, delta); - + const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id); return ( <>
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 8f8aa46abd..588ac08015 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,122 +1,75 @@ -import { useCallback, useContext, useMemo, useRef, useEffect } from 'react'; +import { useCallback, useContext, useMemo, useRef, useEffect, useState } from 'react'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { TextDelta } from '$app/interfaces/document'; -import { debounce } from '@/appflowy_app/utils/tool'; import { NodeContext } from './SubscribeNode.hooks'; -import { BlockActionTypePB } from '@/services/backend/models/flowy-document2'; import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store'; -import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice'; -import { createEditor, Transforms } from 'slate'; +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 { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice'; +import { deltaToSlateValue, getDeltaFromSlateNodes } from '@/appflowy_app/utils/block'; -export function useTextInput(id: string, delta: TextDelta[]) { - const { sendDelta } = useTransact(); - const { editor, yText } = useBindYjs(delta, sendDelta); +export function useTextInput(id: string) { const dispatch = useAppDispatch(); + const node = useContext(NodeContext); + + const delta = useMemo(() => { + if (!node || !('delta' in node.data)) { + return []; + } + return node.data.delta; + }, [node?.data]); + + const { editor, yText } = useBindYjs(id, delta); + + useEffect(() => { + return () => { + dispatch(documentActions.removeTextSelection(id)); + }; + }, [id]); + + const [value, setValue] = useState([]); + + const onChange = useCallback((e: Descendant[]) => { + setValue(e); + }, []); + const currentSelection = useAppSelector((state) => state.document.textSelections[id]); useEffect(() => { - if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) return; - ReactEditor.focus(editor); - Transforms.select(editor, currentSelection); - }, [currentSelection, editor]); + setSelection(editor, currentSelection); + }, [editor, currentSelection]); - const onSelectionChange = useCallback( - (selection?: TextSelection) => { - dispatch( - documentActions.setTextSelection({ - blockId: id, - selection, - }) - ); - }, - [id] - ); + 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 { editor, yText, - onSelectionChange, + onChange, + value, }; } - -function useController() { - const docController = useContext(DocumentControllerContext); - const node = useContext(NodeContext); - const dispatch = useAppDispatch(); - - const update = useCallback( - async (delta: TextDelta[]) => { - if (!docController || !node) return; - await docController.applyActions([ - { - action: BlockActionTypePB.Update, - payload: { - block: { - id: node.id, - ty: node.type, - parent_id: node.parent || '', - children_id: node.children, - data: JSON.stringify({ - ...node.data, - delta, - }), - }, - }, - }, - ]); - dispatch( - documentActions.setBlockMap({ - ...node, - data: { - delta, - }, - }) - ); - }, - [docController, node] - ); - - return { - update, - }; -} - -function useTransact() { - const { update } = useController(); - - const sendDelta = useCallback( - (delta: TextDelta[]) => { - void update(delta); - }, - [update] - ); - const debounceSendDelta = useMemo(() => debounce(sendDelta, 300), [sendDelta]); - - return { - sendDelta: debounceSendDelta, - }; -} - -const initialValue = [ - { - type: 'paragraph', - children: [{ text: '' }], - }, -]; - -function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) { +function useBindYjs(id: string, delta: TextDelta[]) { + const { sendDelta } = useController(id); const yTextRef = useRef(); + // Create a yjs document and get the shared type const sharedType = useMemo(() => { const doc = new Y.Doc(); const _sharedType = doc.get('content', Y.XmlText) as Y.XmlText; - const insertDelta = slateNodesToInsertDelta(initialValue); + const insertDelta = slateNodesToInsertDelta(deltaToSlateValue(delta)); // Load the initial value into the yjs document _sharedType.applyDelta(insertDelta); @@ -141,18 +94,80 @@ function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) { if (!yText) return; const textEventHandler = (event: Y.YTextEvent) => { const textDelta = event.target.toDelta(); - update(textDelta); + void sendDelta(textDelta); }; - if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) { - yText.delete(0, yText.length); - yText.applyDelta(delta); - } + yText.observe(textEventHandler); return () => { yText.unobserve(textEventHandler); }; - }, [delta]); + }, [sendDelta]); + + const currentSelection = useAppSelector((state) => state.document.textSelections[id]); + + useEffect(() => { + const yText = yTextRef.current; + if (!yText) return; + + // If the delta is not equal to the current yText, then we need to update the yText + if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) { + yText.delete(0, yText.length); + yText.applyDelta(delta); + // It should be noted that the selection will be lost after the yText is updated + setSelection(editor, currentSelection); + } + }, [delta, currentSelection, editor]); return { editor, yText: yTextRef.current }; } + +function useController(id: string) { + const docController = useContext(DocumentControllerContext); + const dispatch = useAppDispatch(); + + const sendDelta = useCallback( + async (delta: TextDelta[]) => { + if (!docController) return; + await dispatch( + updateNodeDeltaThunk({ + id, + delta, + controller: docController, + }) + ); + }, + [docController, id] + ); + + return { + sendDelta, + }; +} + +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); + return; + } + + // If the editor is focused and the current selection is the same as the editor's selection, no need to set the selection + if (ReactEditor.isFocused(editor) && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) { + return; + } + + const { path, offset } = currentSelection.focus; + // It is possible that the current selection is out of range + const children = getDeltaFromSlateNodes(editor.children); + if (children[path[1]].insert.length < offset) { + return; + } + + // the order of the following two lines is important + // if we reverse the order, the selection will be lost or always at the start + Transforms.select(editor, currentSelection); + editor.selection = currentSelection; + ReactEditor.focus(editor); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 1834968d68..d5672ffde3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -15,18 +15,21 @@ export enum BlockType { export interface HeadingBlockData { level: number; + delta: TextDelta[]; } export interface TextBlockData { delta: TextDelta[]; } -export interface PageBlockData extends TextBlockData {} +export type PageBlockData = TextBlockData; + +export type BlockData = TextBlockData | HeadingBlockData | PageBlockData; export interface NestedBlock { id: string; type: BlockType; - data: Record; + data: BlockData | Record; parent: string | null; children: string; } 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 9487be8c9b..7bca163c64 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 @@ -5,7 +5,7 @@ import { FlowyError, BlockActionPB, DocEventPB, DeltaTypePB, BlockActionTypePB } import { DocumentObserver } from './document_observer'; import { documentActions, Node } from '@/appflowy_app/stores/reducers/document/slice'; import { Log } from '@/appflowy_app/utils/log'; - +import * as Y from 'yjs'; export const DocumentControllerContext = createContext(null); export class DocumentController { @@ -69,6 +69,8 @@ export class DocumentController { }; getInsertAction = (node: Node, prevId: string | null) => { + // Here to make sure the delta is correct + this.composeDelta(node); return { action: BlockActionTypePB.Insert, payload: this.getActionPayloadByNode(node, prevId), @@ -76,6 +78,8 @@ export class DocumentController { }; getUpdateAction = (node: Node) => { + // Here to make sure the delta is correct + this.composeDelta(node); return { action: BlockActionTypePB.Update, payload: this.getActionPayloadByNode(node, ''), @@ -124,11 +128,25 @@ export class DocumentController { }; }; + private composeDelta = (node: Node) => { + const delta = node.data.delta; + if (!delta) { + return; + } + // we use yjs to compose delta, it can make sure the delta is correct + // for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }] + // but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }] + const ydoc = new Y.Doc(); + const ytext = ydoc.getText(node.id); + ytext.applyDelta(delta); + Object.assign(node.data, { delta: ytext.toDelta() }); + }; + private updated = (payload: Uint8Array) => { const dispatch = this.dispatch; if (!dispatch) return; const { events, is_remote } = DocEventPB.deserializeBinary(payload); - console.log('updated', events, is_remote); + if (!is_remote) return; events.forEach((event) => { event.event.forEach((_payload) => { 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/backspace.ts index eedf951c2e..e7952348fa 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/backspace.ts @@ -3,6 +3,54 @@ import { DocumentController } from '$app/stores/effects/document/document_contro import { createAsyncThunk } from '@reduxjs/toolkit'; import { documentActions, DocumentState } from '../slice'; import { outdentNodeThunk } from './outdent'; +import { setCursorAfterThunk } from './set_cursor'; + +const composeNodeThunk = createAsyncThunk( + 'document/composeNode', + async (payload: { id: string; composeId: string; controller: DocumentController }, thunkAPI) => { + const { id, composeId, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + + const composeNode = state.nodes[composeId]; + // set cursor in compose node end + // It must be stored before update, for the cursor can be restored after update + await dispatch(setCursorAfterThunk({ id: composeId })); + + // merge delta and update + const nodeDelta = node.data?.delta || []; + const composeDelta = composeNode.data?.delta || []; + const newNode = { + ...composeNode, + data: { + ...composeNode.data, + delta: [...composeDelta, ...nodeDelta], + }, + }; + 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, ''); + }); + + // delete node + const deleteAction = controller.getDeleteAction(node); + + // move must be before delete + await controller.applyActions([...moveActions, deleteAction, updateAction]); + + children.reverse().forEach((childId) => { + dispatch(documentActions.moveNode({ id: childId, newParentId: newNode.id, newPrevId: '' })); + }); + dispatch(documentActions.setBlockMap(newNode)); + dispatch(documentActions.removeBlockMapKey(node.id)); + dispatch(documentActions.removeChildrenMapKey(node.children)); + } +); const composeParentThunk = createAsyncThunk( 'document/composeParent', @@ -12,30 +60,18 @@ const composeParentThunk = createAsyncThunk( const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; if (!node.parent) return; - const parent = state.nodes[node.parent]; - // merge delta - const newParent = { - ...parent, - data: { - ...parent.data, - delta: [...parent.data.delta, ...node.data.delta], - }, - }; - await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newParent)]); - - dispatch(documentActions.setBlockMap(newParent)); - dispatch(documentActions.removeBlockMapKey(node.id)); - dispatch(documentActions.removeChildrenMapKey(node.children)); + await dispatch(composeNodeThunk({ id: id, composeId: node.parent, controller })); } ); + const composePrevNodeThunk = createAsyncThunk( 'document/composePrevNode', async (payload: { prevNodeId: string; id: string; controller: DocumentController }, thunkAPI) => { const { id, prevNodeId, controller } = payload; const { dispatch, getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; const prevNode = state.nodes[prevNodeId]; + if (!prevNode) return; // find prev line let prevLineId = prevNode.id; while (prevLineId) { @@ -43,20 +79,7 @@ const composePrevNodeThunk = createAsyncThunk( if (prevLineChildren.length === 0) break; prevLineId = prevLineChildren[prevLineChildren.length - 1]; } - const prevLine = state.nodes[prevLineId]; - // merge delta - const newPrevLine = { - ...prevLine, - data: { - ...prevLine.data, - delta: [...prevLine.data.delta, ...node.data.delta], - }, - }; - await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newPrevLine)]); - - dispatch(documentActions.setBlockMap(newPrevLine)); - dispatch(documentActions.removeBlockMapKey(node.id)); - dispatch(documentActions.removeChildrenMapKey(node.children)); + await dispatch(composeNodeThunk({ id: id, composeId: prevLineId, controller })); } ); @@ -80,6 +103,8 @@ export const backspaceNodeThunk = createAsyncThunk( } // compose to previous line when it has next sibling or no ancestor if (nextNodeId || !ancestorId) { + // do nothing when it is the first line + if (!prevNodeId && !ancestorId) return; // compose to parent when it has no previous sibling if (!prevNodeId) { await dispatch(composeParentThunk({ id, controller })); 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/insert.ts index 3724c3fb83..1ab0f69d10 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/insert.ts @@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro import { createAsyncThunk } from '@reduxjs/toolkit'; import { documentActions, DocumentState } from '../slice'; import { generateId } from '@/appflowy_app/utils/block'; +import { setCursorAfterThunk } from './set_cursor'; export const insertAfterNodeThunk = createAsyncThunk( 'document/insertAfterNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { @@ -18,7 +19,9 @@ export const insertAfterNodeThunk = createAsyncThunk( id: generateId(), parent: parentId, type: BlockType.TextBlock, - data: {}, + data: { + delta: [], + }, children: generateId(), }; await controller.applyActions([controller.getInsertAction(newNode, node.id)]); @@ -37,5 +40,6 @@ export const insertAfterNodeThunk = createAsyncThunk( prevId: node.id, }) ); + await dispatch(setCursorAfterThunk({ id: newNode.id })); } ); 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/set_cursor.ts new file mode 100644 index 0000000000..8c903cb856 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts @@ -0,0 +1,47 @@ +import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState, SelectionPoint, TextSelection } from '../slice'; + +export const setCursorBeforeThunk = createAsyncThunk( + 'document/setCursorBefore', + async (payload: { id: string }, thunkAPI) => { + const { id } = payload; + const { dispatch } = thunkAPI; + const selection: TextSelection = { + anchor: { + path: [0, 0], + offset: 0, + }, + focus: { + path: [0, 0], + offset: 0, + }, + }; + dispatch(documentActions.setTextSelection({ blockId: id, selection })); + } +); + +export const setCursorAfterThunk = createAsyncThunk( + 'document/setCursorAfter', + async (payload: { id: string }, thunkAPI) => { + const { id } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + const len = node.data.delta?.length || 0; + const offset = len > 0 ? node.data.delta[len - 1].insert.length : 0; + const cursorPoint: SelectionPoint = { + path: [0, len > 0 ? len - 1 : 0], + offset, + }; + const selection: TextSelection = { + anchor: { + ...cursorPoint, + }, + focus: { + ...cursorPoint, + }, + }; + dispatch(documentActions.setTextSelection({ blockId: node.id, selection })); + } +); 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/split.ts index ac2be1350b..a06d13365d 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/split.ts @@ -2,7 +2,8 @@ import { BlockType, TextDelta } from '@/appflowy_app/interfaces/document'; import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { generateId } from '@/appflowy_app/utils/block'; -import { documentActions, DocumentState } from '../slice'; +import { documentActions, DocumentState, TextSelection } from '../slice'; +import { setCursorBeforeThunk } from './set_cursor'; export const splitNodeThunk = createAsyncThunk( 'document/splitNode', @@ -50,5 +51,8 @@ export const splitNodeThunk = createAsyncThunk( prevId, }) ); + + // set cursor + await dispatch(setCursorBeforeThunk({ id: newNode.id })); } ); 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/update.ts new file mode 100644 index 0000000000..4a31a98676 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts @@ -0,0 +1,39 @@ +import { TextDelta } from '@/appflowy_app/interfaces/document'; +import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { documentActions, DocumentState, Node } from '../slice'; +import { debounce } from '$app/utils/tool'; +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 state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + const updateNode = { + ...node, + id, + data: { + ...node.data, + delta, + }, + }; + // The block map should be updated immediately + // or the component will use the old data to update the editor + dispatch(documentActions.setBlockMap(updateNode)); + + // the transaction is delayed to avoid too many updates + debounceApplyUpdate(controller, updateNode); + } +); + +const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: Node) => { + void controller.applyActions([ + controller.getUpdateAction({ + ...updateNode, + data: { + ...updateNode.data, + }, + }), + ]); +}, 200); 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 19235be4ce..d83b194573 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 @@ -114,7 +114,8 @@ export const documentSlice = createSlice({ }> ) => { const { blockId, selection } = action.payload; - if (!selection) { + const node = state.nodes[blockId]; + if (!node || !selection) { delete state.textSelections[blockId]; } else { state.textSelections = { @@ -123,11 +124,28 @@ export const documentSlice = createSlice({ } }, - // update block + // remove text selections + removeTextSelection: (state, action: PayloadAction) => { + const id = action.payload; + if (!state.textSelections[id]) return; + state.textSelections; + }, + + // insert block setBlockMap: (state, action: PayloadAction) => { state.nodes[action.payload.id] = action.payload; }, + // update block when `type`, `parent` or `children` changed + updateBlock: (state, action: PayloadAction<{ id: string; block: NestedBlock }>) => { + const { id, block } = action.payload; + const node = state.nodes[id]; + if (!node || node.parent !== block.parent || node.type !== block.type || node.children !== block.children) { + state.nodes[action.payload.id] = block; + return; + } + }, + // remove block removeBlockMapKey(state, action: PayloadAction) { if (!state.nodes[action.payload]) return; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts index 6318f80616..dbb519e052 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts @@ -1,5 +1,36 @@ import { nanoid } from 'nanoid'; +import { Descendant, Element, Text } from 'slate'; +import { TextDelta } from '../interfaces/document'; export function generateId() { return nanoid(10); -} \ No newline at end of file +} + +export function deltaToSlateValue(delta: TextDelta[]) { + const slateNode = { + type: 'paragraph', + children: [{ text: '' }], + }; + const slateNodes = [slateNode]; + if (delta.length > 0) { + slateNode.children = delta.map((d) => { + return { + ...d.attributes, + text: d.insert, + }; + }); + } + return slateNodes; +} + +export function getDeltaFromSlateNodes(slateNodes: Descendant[]) { + const element = slateNodes[0] as Element; + const children = element.children as Text[]; + return children.map((child) => { + const { text, ...attributes } = child; + return { + insert: text, + attributes, + }; + }); +} diff --git a/frontend/rust-lib/flowy-document2/src/event_handler.rs b/frontend/rust-lib/flowy-document2/src/event_handler.rs index ccb6d91b0e..07295b9dd2 100644 --- a/frontend/rust-lib/flowy-document2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document2/src/event_handler.rs @@ -138,8 +138,8 @@ impl From for DeltaTypePB { } } -impl DocEventPB { - pub(crate) fn get_from(events: &Vec, is_remote: bool) -> Self { +impl From<(&Vec, bool)> for DocEventPB { + fn from((events, is_remote): (&Vec, bool)) -> Self { Self { events: events.iter().map(|e| e.to_owned().into()).collect(), is_remote, diff --git a/frontend/rust-lib/flowy-document2/src/manager.rs b/frontend/rust-lib/flowy-document2/src/manager.rs index 39428047a8..6e1ed88b3d 100644 --- a/frontend/rust-lib/flowy-document2/src/manager.rs +++ b/frontend/rust-lib/flowy-document2/src/manager.rs @@ -60,7 +60,7 @@ impl DocumentManager { .lock() .open(move |events, is_remote| { send_notification(&clone_doc_id, DocumentNotification::DidReceiveUpdate) - .payload(DocEventPB::get_from(events, is_remote)) + .payload::((events, is_remote).into()) .send(); }) .map_err(|err| FlowyError::internal().context(err))?;