fix: set cursor after operation (#2343)

This commit is contained in:
qinluhe 2023-04-25 15:52:57 +08:00 committed by GitHub
parent 973cd9194d
commit 1ad2f6cef5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 440 additions and 217 deletions

View File

@ -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<string, string> = {
1: 'mt-8 text-3xl',
@ -8,9 +8,15 @@ const fontSize: Record<string, string> = {
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 (
<div className={`${fontSize[node.data.style?.level]} font-semibold `}>
<div className={`${fontSize[node.data.level]} font-semibold `}>
{/*<TextBlock node={node} childIds={[]} delta={delta} />*/}
</div>
);

View File

@ -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<string, any>;
};
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
<ColumnBlock
key={item}
index={index}
width={`calc((100% - ${resizerWidth}px) * ${node.data.style?.ratio})`}
width={`calc((100% - ${resizerWidth}px) * ${node.data.ratio})`}
id={item}
/>
))}

View File

@ -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 (
<div className='flex-1'>
{/*<TextBlock delta={delta} node={node} childIds={[]} />*/}
</div>
);
}, [node, delta]);
// if (node.data.style?.type === 'column') return <></>;
return <div className='flex-1'>{/*<TextBlock delta={delta} node={node} childIds={[]} />*/}</div>;
}, [node]);
if (node.data.style?.type === 'numbered') {
return <NumberedListBlock title={title} node={node} />;
}
if (node.data.style?.type === 'bulleted') {
return <BulletedListBlock title={title} node={node} />;
}
if (node.data.style?.type === 'column') {
return <ColumnListBlock node={node} />;
}
// if (node.data.type === 'numbered') {
// return <NumberedListBlock title={title} node={node} />;
// }
//
// if (node.data.type === 'bulleted') {
// return <BulletedListBlock title={title} node={node} />;
// }
//
// if (node.data.type === 'column') {
// return <ColumnListBlock node={node} />;
// }
return null;
}

View File

@ -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<Descendant[]>([]);
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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".

View File

@ -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<HTMLDivElement>) {
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 (
<>
<div {...props} className={`py-[2px] ${props.className}`}>

View File

@ -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<Descendant[]>([]);
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<Y.XmlText>();
// 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);
}

View File

@ -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<string, any>;
data: BlockData | Record<string, any>;
parent: string | null;
children: string;
}

View File

@ -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<DocumentController | null>(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) => {

View File

@ -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 }));

View File

@ -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 }));
}
);

View File

@ -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 }));
}
);

View File

@ -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 }));
}
);

View File

@ -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);

View File

@ -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<string>) => {
const id = action.payload;
if (!state.textSelections[id]) return;
state.textSelections;
},
// insert block
setBlockMap: (state, action: PayloadAction<Node>) => {
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<string>) {
if (!state.nodes[action.payload]) return;

View File

@ -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);
}
}
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,
};
});
}

View File

@ -138,8 +138,8 @@ impl From<DeltaType> for DeltaTypePB {
}
}
impl DocEventPB {
pub(crate) fn get_from(events: &Vec<BlockEvent>, is_remote: bool) -> Self {
impl From<(&Vec<BlockEvent>, bool)> for DocEventPB {
fn from((events, is_remote): (&Vec<BlockEvent>, bool)) -> Self {
Self {
events: events.iter().map(|e| e.to_owned().into()).collect(),
is_remote,

View File

@ -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::<DocEventPB>((events, is_remote).into())
.send();
})
.map_err(|err| FlowyError::internal().context(err))?;