mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: set cursor after operation (#2343)
This commit is contained in:
parent
973cd9194d
commit
1ad2f6cef5
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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".
|
||||
|
@ -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}`}>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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 }));
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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);
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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))?;
|
||||
|
Loading…
Reference in New Issue
Block a user