Support range selection and refactor data update mechanism and optimize outdent/indent operations (#2514)

* refactor: simplify data update logic and optimize outdent/indent operations

* feat: support range selection

* fix: review suggestions
This commit is contained in:
Kilu.He 2023-05-16 10:54:40 +08:00 committed by GitHub
parent f9862c501c
commit 99c48f7100
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 641 additions and 380 deletions

View File

@ -24,6 +24,7 @@ export function useHoveringToolbar(id: string) {
el.style.left = position.left;
}
});
return {
ref,
inFocus,

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppSelector } from '$app/stores/store';
import { RegionGrid } from '$app/utils/region_grid';
@ -6,9 +6,7 @@ import { RegionGrid } from '$app/utils/region_grid';
export function useNodesRect(container: HTMLDivElement) {
const controller = useContext(DocumentControllerContext);
const data = useAppSelector((state) => {
return state.document;
});
const version = useVersionUpdate();
const regionGrid = useMemo(() => {
if (!controller) return null;
@ -40,7 +38,7 @@ export function useNodesRect(container: HTMLDivElement) {
// update nodes rect when data changed
useEffect(() => {
updateViewPortNodesRect();
}, [data, updateViewPortNodesRect]);
}, [version, updateViewPortNodesRect]);
// update nodes rect when scroll
useEffect(() => {
@ -74,3 +72,19 @@ export function useNodesRect(container: HTMLDivElement) {
getIntersectedBlockIds,
};
}
function useVersionUpdate() {
const [version, setVersion] = useState(0);
const data = useAppSelector((state) => {
return state.document;
});
useEffect(() => {
setVersion((v) => {
if (v < Number.MAX_VALUE) return v + 1;
return 0;
});
}, [data]);
return version;
}

View File

@ -1,57 +1,50 @@
import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
import { useAppSelector } from '@/appflowy_app/stores/store';
import { debounce } from '@/appflowy_app/utils/tool';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BlockType, HeadingBlockData, NestedBlock } from "@/appflowy_app/interfaces/document";
import { useAppDispatch } from "@/appflowy_app/stores/store";
import { useCallback, useEffect, useRef, useState } from 'react';
import { getBlockByIdThunk } from "$app_reducers/document/async-actions";
const headingBlockTopOffset: Record<number, number> = {
1: 7,
2: 6,
3: 3,
};
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
const [nodeId, setHoverNodeId] = useState<string>('');
const [nodeId, setHoverNodeId] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
const nodes = useAppSelector((state) => state.document.nodes);
const nodesRef = useRef(nodes);
const handleMouseMove = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e;
const x = clientX;
const y = clientY;
const id = getNodeIdByPoint(x, y);
if (!id) {
setHoverNodeId('');
} else {
if ([BlockType.ColumnBlock].includes(nodesRef.current[id].type)) {
setHoverNodeId('');
return;
}
setHoverNodeId(id);
}
}, []);
const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]);
const dispatch = useAppDispatch();
const [style, setStyle] = useState<React.CSSProperties>({});
useEffect(() => {
const el = ref.current;
if (!el || !nodeId) return;
void(async () => {
const{ payload: node } = await dispatch(getBlockByIdThunk(nodeId)) as {
payload: NestedBlock;
};
if (!node) {
setStyle({
opacity: '0',
pointerEvents: 'none',
});
return;
} else {
let top = 1;
const node = nodesRef.current[nodeId];
if (!node) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
} else {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
el.style.top = '1px';
if (node?.type === BlockType.HeadingBlock) {
const nodeData = node.data as HeadingBlockData;
if (nodeData.level === 1) {
el.style.top = '8px';
} else if (nodeData.level === 2) {
el.style.top = '6px';
} else {
el.style.top = '5px';
if (node.type === BlockType.HeadingBlock) {
const nodeData = node.data as HeadingBlockData;
top = headingBlockTopOffset[nodeData.level];
}
setStyle({
opacity: '1',
pointerEvents: 'auto',
top: `${top}px`,
});
}
}
}, [nodeId]);
})();
}, [dispatch, nodeId]);
const handleToggleMenu = useCallback((isOpen: boolean) => {
setMenuOpen(isOpen);
@ -60,22 +53,25 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
}
}, []);
useEffect(() => {
container.addEventListener('mousemove', debounceMove);
return () => {
container.removeEventListener('mousemove', debounceMove);
};
}, [debounceMove]);
const handleMouseMove = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e;
const id = getNodeIdByPoint(clientX, clientY);
setHoverNodeId(id);
}, []);
useEffect(() => {
nodesRef.current = nodes;
}, [nodes]);
container.addEventListener('mousemove', handleMouseMove);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
};
}, [container, handleMouseMove]);
return {
nodeId,
ref,
handleToggleMenu,
menuOpen,
style
};
}

View File

@ -9,7 +9,7 @@ import BlockMenu from '../BlockMenu';
const sx = { height: 24, width: 24 };
export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
if (!nodeId) return null;
return (
@ -19,6 +19,7 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
ref={ref}
style={{
opacity: 0,
...style,
}}
className='absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
onMouseDown={(e) => {

View File

@ -7,7 +7,7 @@ import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { splitNodeThunk } from '$app_reducers/document/async-actions';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
import { indent, outdent } from '$app/utils/document/blocks/code';
export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {

View File

@ -1,16 +1,13 @@
import React from 'react';
import { useDocumentTitle } from './DocumentTitle.hooks';
import TextBlock from '../TextBlock';
import { NodeContext } from '../_shared/SubscribeNode.hooks';
export default function DocumentTitle({ id }: { id: string }) {
const { node } = useDocumentTitle(id);
if (!node) return null;
return (
<NodeContext.Provider value={node}>
<div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
<TextBlock placeholder='Untitled' childIds={[]} node={node} />
</div>
</NodeContext.Provider>
<div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
<TextBlock placeholder='Untitled' childIds={[]} node={node} />
</div>
);
}

View File

@ -3,7 +3,6 @@ import { useNode } from './Node.hooks';
import { withErrorBoundary } from 'react-error-boundary';
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
import TextBlock from '../TextBlock';
import { NodeContext } from '../_shared/SubscribeNode.hooks';
import { BlockType } from '$app/interfaces/document';
import { Alert } from '@mui/material';
@ -59,15 +58,13 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
if (!node) return null;
return (
<NodeContext.Provider value={node}>
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
{renderBlock()}
<div className='block-overlay' />
{isSelected ? (
<div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
) : null}
</div>
</NodeContext.Provider>
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
{renderBlock()}
<div className='block-overlay' />
{isSelected ? (
<div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
) : null}
</div>
);
}

View File

@ -1,19 +1,20 @@
import { BaseText } from 'slate';
import { RenderLeafProps } from 'slate-react';
const Leaf = ({
attributes,
children,
leaf,
}: RenderLeafProps & {
interface LeafProps extends RenderLeafProps {
leaf: BaseText & {
bold?: boolean;
code?: boolean;
italic?: boolean;
underlined?: boolean;
strikethrough?: boolean;
selectionHighlighted?: boolean;
};
}) => {
}
const Leaf = ({
attributes,
children,
leaf,
}: LeafProps) => {
let newChildren = children;
if (leaf.bold) {
newChildren = <strong>{children}</strong>;
@ -31,8 +32,16 @@ const Leaf = ({
newChildren = <u>{newChildren}</u>;
}
let className = "";
if (leaf.strikethrough) {
className += "line-through";
}
if (leaf.selectionHighlighted) {
className += " bg-main-secondary";
}
return (
<span {...attributes} className={leaf.strikethrough ? `line-through` : ''}>
<span {...attributes} className={className}>
{newChildren}
</span>
);

View File

@ -2,13 +2,13 @@ import { useTextInput } from '../_shared/Text/TextInput.hooks';
import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
export function useTextBlock(id: string) {
const { editor, ...rest } =
useTextInput(id);
const { editor, ...props } = useTextInput(id);
const { onKeyDown } = useTextBlockKeyEvent(id, editor);
return {
onKeyDown,
editor,
...rest
...props,
};
}

View File

@ -8,9 +8,10 @@ import isHotkey from 'is-hotkey';
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '$app/stores/store';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
import { ReactEditor } from 'slate-react';
export function useTextBlockKeyEvent(id: string, editor: Editor) {
export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();

View File

@ -2,33 +2,28 @@ import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf';
import { useTextBlock } from './TextBlock.hooks';
import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
import React from 'react';
import { BlockType, NestedBlock } from '$app/interfaces/document';
import React, { useEffect } from 'react';
import { NestedBlock } from '$app/interfaces/document';
import NodeChildren from '$app/components/document/Node/NodeChildren';
function TextBlock({
node,
childIds,
placeholder,
...props
className = '',
}: {
node: NestedBlock;
childIds?: string[];
placeholder?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
const {
editor,
value,
onChange,
...rest
} = useTextBlock(node.id);
const className = props.className !== undefined ? ` ${props.className}` : '';
className?: string;
}) {
const { editor, value, onChange, ...rest } = useTextBlock(node.id);
return (
<>
<div {...props} className={`px-1 py-[2px]${className}`}>
<div className={`px-1 py-[2px] ${className}`}>
<Slate editor={editor} onChange={onChange} value={value}>
<BlockHorizontalToolbar id={node.id} />
{/*<BlockHorizontalToolbar id={node.id} />*/}
<Editable
{...rest}
renderLeaf={(leafProps) => <Leaf {...leafProps} />}

View File

@ -1,12 +1,11 @@
import { useAppSelector } from '@/appflowy_app/stores/store';
import { useMemo, createContext } from 'react';
import { Node } from '$app/interfaces/document';
export const NodeContext = createContext<Node | null>(null);
import { useMemo, useRef } from 'react';
import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
import { nodeInRange } from '$app/utils/document/blocks/common';
import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
/**
* Subscribe to a node and its children
* It will be change when the node or its children is changed
* And it will not be change when other node is changed
* Subscribe node information
* @param id
*/
export function useSubscribeNode(id: string) {
@ -19,16 +18,13 @@ export function useSubscribeNode(id: string) {
});
const isSelected = useAppSelector<boolean>((state) => {
return state.rectSelection.selections?.includes(id) || false;
return state.documentRectSelection.includes(id) || false;
});
// Memoize the node and its children
// So that the component will not be re-rendered when other node is changed
// It very important for performance
const memoizedNode = useMemo(
() => node,
[JSON.stringify(node)]
);
const memoizedNode = useMemo(() => node, [JSON.stringify(node)]);
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
return {
@ -37,3 +33,55 @@ export function useSubscribeNode(id: string) {
isSelected,
};
}
/**
* Subscribe selection information
* @param id
*/
export function useSubscribeRangeSelection(id: string) {
const rangeRef = useRef<RangeSelectionState>();
const currentSelection = useAppSelector((state) => {
const range = state.documentRangeSelection;
rangeRef.current = range;
if (range.anchor?.id === id) {
return range.anchor.selection;
}
if (range.focus?.id === id) {
return range.focus.selection;
}
return getAmendInRangeNodeSelection(id, range, state.document);
});
return {
rangeRef,
currentSelection,
};
}
function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
if (!range.anchor || !range.focus || range.anchor.id === range.focus.id) {
return null;
}
const isForward = selectionIsForward(range.anchor.selection);
const isNodeInRange = nodeInRange(
id,
{
startId: range.anchor.id,
endId: range.focus.id,
},
isForward,
document
);
if (isNodeInRange) {
const delta = document.nodes[id].data.delta;
return {
anchor: {
path: [0, 0],
offset: 0,
},
focus: getNodeEndSelection(delta).anchor,
};
}
}

View File

@ -1,7 +1,6 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { Editor } from 'slate';
import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import {
@ -12,20 +11,21 @@ import {
canHandleUpKey,
} from '$app/utils/document/blocks/text/hotkey';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { ReactEditor } from "slate-react";
export function useDefaultTextInputEvents(id: string) {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const focusPreLineAction = useCallback(
async (params: { editor: Editor; focusEnd?: boolean }) => {
async (params: { editor: ReactEditor; focusEnd?: boolean }) => {
await dispatch(setCursorPreLineThunk({ id, ...params }));
},
[dispatch, id]
);
const focusNextLineAction = useCallback(
async (params: { editor: Editor; focusStart?: boolean }) => {
async (params: { editor: ReactEditor; focusStart?: boolean }) => {
await dispatch(setCursorNextLineThunk({ id, ...params }));
},
[dispatch, id]
@ -35,6 +35,8 @@ export function useDefaultTextInputEvents(id: string) {
triggerEventKey: keyBoardEventKeyMap.Up,
canHandle: canHandleUpKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusPreLineAction({
editor: args[1],
});
@ -44,6 +46,8 @@ export function useDefaultTextInputEvents(id: string) {
triggerEventKey: keyBoardEventKeyMap.Down,
canHandle: canHandleDownKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusNextLineAction({
editor: args[1],
});
@ -53,6 +57,8 @@ export function useDefaultTextInputEvents(id: string) {
triggerEventKey: keyBoardEventKeyMap.Left,
canHandle: canHandleLeftKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusPreLineAction({
editor: args[1],
focusEnd: true,
@ -63,6 +69,8 @@ export function useDefaultTextInputEvents(id: string) {
triggerEventKey: keyBoardEventKeyMap.Right,
canHandle: canHandleRightKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusNextLineAction({
editor: args[1],
focusStart: true,

View File

@ -1,22 +1,22 @@
import { createEditor, Descendant, Transforms, Element, Text, Editor } from 'slate';
import { ReactEditor, withReact } from 'slate-react';
import { createEditor, Descendant, Editor } from 'slate';
import { withReact } from 'slate-react';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { TextDelta, TextSelection } from '$app/interfaces/document';
import { NodeContext } from '../SubscribeNode.hooks';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { TextDelta } from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { deltaToSlateValue, getCollapsedRange, slateValueToDelta } from "$app/utils/document/blocks/common";
import { rangeSelectionActions } from "$app_reducers/document/slice";
import { getNodeEndSelection, isSameDelta } from '$app/utils/document/blocks/text/delta';
import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
import { debounce } from '$app/utils/tool';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
export function useTextInput(id: string) {
const { node } = useSubscribeNode(id);
const [editor] = useState(() => withReact(createEditor()));
const node = useContext(NodeContext);
const { sendDelta } = useController(id);
const { storeSelection } = useSelection(id, editor);
const isComposition = useRef(false);
const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
const delta = useMemo(() => {
if (!node || !('delta' in node.data)) {
@ -24,38 +24,30 @@ export function useTextInput(id: string) {
}
return node.data.delta;
}, [node]);
const { sync, receive } = useUpdateDelta(id, editor);
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
// Update the editor's value when the node's delta changes.
useEffect(() => {
// If composition is in progress, do nothing.
if (isComposition.current) return;
// If the delta is the same as the editor's value, do nothing.
const localDelta = slateValueToDelta(editor.children);
const isSame = isSameDelta(delta, localDelta);
if (isSame) return;
const slateValue = deltaToSlateValue(delta);
editor.children = slateValue;
setValue(slateValue);
}, [delta, editor]);
receive(delta);
}, [delta, receive]);
// Update the node's delta when the editor's value changes.
const onChange = useCallback(
(e: Descendant[]) => {
// Update the editor's value and selection.
setValue(e);
storeSelection();
// If the selection is not null, update the last active selection.
if (editor.selection !== null) setLastActiveSelection(editor.selection);
// If composition is in progress, do nothing.
if (isComposition.current) return;
// Update the node's delta
const textDelta = slateValueToDelta(e);
void sendDelta(textDelta);
sync();
},
[sendDelta, storeSelection]
[editor.selection, setLastActiveSelection, sync]
);
const onDOMBeforeInput = useCallback((e: InputEvent) => {
@ -83,6 +75,7 @@ export function useTextInput(id: string) {
editor,
onChange,
value,
...selectionProps,
onDOMBeforeInput,
onCompositionStart,
onCompositionUpdate,
@ -90,118 +83,60 @@ export function useTextInput(id: string) {
};
}
function useController(id: string) {
const docController = useContext(DocumentControllerContext);
function useUpdateDelta(id: string, editor: Editor) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const penddingRef = useRef(false);
const sendDelta = useCallback(
async (delta: TextDelta[]) => {
if (!docController) return;
await dispatch(
updateNodeDeltaThunk({
id,
delta,
controller: docController,
})
);
},
[dispatch, docController, id]
);
// when user input, update the node's delta after 200ms
const debounceUpdate = useMemo(() => {
return debounce(() => {
if (!controller) return;
const delta = slateValueToDelta(editor.children);
void (async () => {
await dispatch(
updateNodeDeltaThunk({
id,
delta,
controller,
})
);
// reset pendding flag
penddingRef.current = false;
})();
}, 200);
}, [controller, dispatch, editor, id]);
return {
sendDelta,
};
}
const sync = useCallback(() => {
// set pendding flag
penddingRef.current = true;
debounceUpdate();
}, [debounceUpdate]);
function useSelection(id: string, editor: ReactEditor) {
const dispatch = useAppDispatch();
const selectionRef = useRef<TextSelection | null>(null);
const currentSelection = useAppSelector((state) => {
const range = state.rangeSelection;
if (!range.anchor || !range.focus) return null;
if (range.anchor.id === id) {
return range.anchor.selection;
}
if (range.focus.id === id) {
return range.focus.selection;
}
return null;
});
const receive = useCallback(
(delta: TextDelta[]) => {
// if pendding, do nothing
if (penddingRef.current) return;
// whether the selection is out of range.
const outOfRange = useCallback(
(selection: TextSelection) => {
const point = Editor.end(editor, selection);
const { path, offset } = point;
// path length is 2, because the editor is a single text node.
const [i, j] = path;
const children = editor.children[i] as Element;
if (!children) return true;
const child = children.children[j] as Text;
return child.text.length < offset;
// If the delta is the same as the editor's value, do nothing.
const localDelta = slateValueToDelta(editor.children);
const isSame = isSameDelta(delta, localDelta);
if (isSame) return;
const slateValue = deltaToSlateValue(delta);
editor.children = slateValue;
},
[editor]
);
// store the selection
const storeSelection = useCallback(() => {
// do nothing if the node is not focused.
if (!ReactEditor.isFocused(editor)) {
selectionRef.current = null;
return;
}
// set selection to the end of the node if the selection is out of range.
if (outOfRange(editor.selection as TextSelection)) {
editor.selection = getNodeEndSelection(slateValueToDelta(editor.children));
selectionRef.current = null;
}
let selection = editor.selection as TextSelection;
// the selection will sometimes be cleared after the editor is focused.
// so we need to restore the selection when selection ref is not null.
if (selectionRef.current && JSON.stringify(editor.selection) !== JSON.stringify(selectionRef.current)) {
Transforms.select(editor, selectionRef.current);
selection = selectionRef.current;
}
selectionRef.current = null;
const range = getCollapsedRange(id, selection);
dispatch(rangeSelectionActions.setRange(range));
}, [dispatch, editor, id, outOfRange]);
// restore the selection
const restoreSelection = useCallback((selection: TextSelection | null) => {
if (!selection) return;
// do nothing if the selection is out of range
if (outOfRange(selection)) return;
if (ReactEditor.isFocused(editor)) {
// if the editor is focused, set the selection directly.
if (JSON.stringify(selection) === JSON.stringify(editor.selection)) return;
Transforms.select(editor, selection);
} else {
// Here we store the selection in the ref,
// because the selection will sometimes be cleared after the editor is focused.
selectionRef.current = selection;
Transforms.select(editor, selection);
ReactEditor.focus(editor);
}
}, [editor, outOfRange]);
useEffect(() => {
restoreSelection(currentSelection);
}, [restoreSelection, currentSelection]);
if (editor.selection && ReactEditor.isFocused(editor)) {
const domSelection = window.getSelection();
// this is a hack to fix the issue where the selection is not in the dom
if (domSelection?.rangeCount === 0) {
const range = ReactEditor.toDOMRange(editor, editor.selection);
domSelection.addRange(range);
}
}
return () => {
debounceUpdate.cancel();
};
});
return {
storeSelection,
sync,
receive,
};
}

View File

@ -0,0 +1,113 @@
import { MouseEventHandler, useCallback, useEffect } from 'react';
import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
import { EditableProps } from 'slate-react/dist/components/editable';
import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useAppDispatch } from '$app/stores/store';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { TextSelection } from '$app/interfaces/document';
import { ReactEditor } from 'slate-react';
import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
import { getCollapsedRange } from '$app/utils/document/blocks/common';
import { getEditorEndPoint, selectionIsForward } from '$app/utils/document/blocks/text/delta';
export function useTextSelections(id: string, editor: ReactEditor) {
const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
const dispatch = useAppDispatch();
useEffect(() => {
if (!rangeRef.current) return;
const { isDragging, focus, anchor } = rangeRef.current;
if (isDragging || anchor?.id !== focus?.id || !currentSelection || !Range.isCollapsed(currentSelection as BaseRange))
return;
if (!ReactEditor.isFocused(editor)) {
ReactEditor.focus(editor);
}
Transforms.select(editor, currentSelection);
}, [currentSelection, editor, rangeRef]);
const decorate: EditableProps['decorate'] = useCallback(
(entry: [Node, Path]) => {
const [node, path] = entry;
if (currentSelection && !Range.isCollapsed(currentSelection as BaseRange)) {
const intersection = Range.intersection(currentSelection, Editor.range(editor, path));
if (!intersection) {
return [];
}
const range = {
selectionHighlighted: true,
...intersection,
};
return [range];
}
return [];
},
[editor, currentSelection]
);
const onMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
const range = getCollapsedRange(id, editor.selection as TextSelection);
dispatch(
rangeSelectionActions.setRange({
...range,
isDragging: true,
})
);
},
[dispatch, editor, id]
);
const onMouseMove: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
if (!rangeRef.current) return;
const { isDragging, anchor } = rangeRef.current;
if (!isDragging || !anchor || ReactEditor.isFocused(editor)) return;
const isForward = selectionIsForward(anchor.selection);
if (!isForward) {
Transforms.select(editor, getEditorEndPoint(editor));
}
ReactEditor.focus(editor);
},
[editor, rangeRef]
);
const onMouseUp: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
if (!rangeRef.current) return;
const { isDragging } = rangeRef.current;
if (!isDragging) return;
dispatch(
rangeSelectionActions.setRange({
isDragging: false,
})
);
},
[dispatch, rangeRef]
);
const setLastActiveSelection = useCallback(
(lastActiveSelection: Range) => {
const selection = lastActiveSelection as TextSelection;
dispatch(syncRangeSelectionThunk({ id, selection }));
},
[dispatch, id]
);
const onBlur = useCallback(() => {
ReactEditor.deselect(editor);
}, [editor]);
return {
decorate,
onMouseDown,
onMouseMove,
onMouseUp,
onBlur,
setLastActiveSelection,
};
}

View File

@ -1,5 +1,6 @@
import { Editor } from 'slate';
import { RegionGrid } from '$app/utils/region_grid';
import { ReactEditor } from "slate-react";
export enum BlockType {
PageBlock = 'page',
@ -131,6 +132,7 @@ export interface DocumentState {
}
export interface RangeSelectionState {
isDragging?: boolean,
anchor?: PointState,
focus?: PointState,
}
@ -158,4 +160,4 @@ export interface BlockPBValue {
data: string;
}
export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, Editor];
export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];

View File

@ -2,7 +2,15 @@ import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { blockConfig } from '$app/constants/document/config';
import { getPrevNodeId } from "$app/utils/document/blocks/common";
/**
* indent node
* 1. if node parent is root, do nothing
* 2. if node parent is not root
* 2.1. get prev node, if prev node is not allowed to have children, do nothing
* 2.2. if prev node is allowed to have children, move node to prev node's last child, and move node's children after node
*/
export const indentNodeThunk = createAsyncThunk(
'document/indentNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@ -11,21 +19,23 @@ export const indentNodeThunk = createAsyncThunk(
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node.parent) return;
// get parent
const parent = state.nodes[node.parent];
// get prev node
const children = state.children[parent.children];
const index = children.indexOf(id);
if (index === 0) return;
const newParentId = children[index - 1];
const prevNode = state.nodes[newParentId];
// check if prev node is allowed to have children
const config = blockConfig[prevNode.type];
if (!config.canAddChild) return;
// check if prev node has children and get last child for new prev node
const prevNodeChildren = state.children[prevNode.children];
const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];
await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
// get prev node
const prevNodeId = getPrevNodeId(state, id);
if (!prevNodeId) return;
const newParentNode = state.nodes[prevNodeId];
// check if prev node is allowed to have children
const config = blockConfig[newParentNode.type];
if (!config.canAddChild) return;
// check if prev node has children and get last child for new prev node
const newParentChildren = state.children[newParentNode.children];
const newPrevId = newParentChildren[newParentChildren.length - 1];
const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
const childrenNodes = state.children[node.children].map(id => state.nodes[id]);
const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
await controller.applyActions([moveAction, ...moveChildrenActions]);
}
);

View File

@ -2,7 +2,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { DocumentState } from '$app/interfaces/document';
import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common";
import { documentActions, rangeSelectionActions } from "$app_reducers/document/slice";
import { rangeSelectionActions } from "$app_reducers/document/slice";
import { blockConfig } from '$app/constants/document/config';
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
@ -40,8 +40,6 @@ export const mergeToPrevLineThunk = createAsyncThunk(
const mergeDelta = [...prevLineDelta, ...node.data.delta];
dispatch(documentActions.updateNodeData({ id: prevLine.id, data: { delta: mergeDelta } }));
const updateAction = controller.getUpdateAction({
...prevLine,
data: {
@ -66,7 +64,6 @@ export const mergeToPrevLineThunk = createAsyncThunk(
actions.push(deleteAction);
} else {
// clear current block delta
dispatch(documentActions.updateNodeData({ id: node.id, data: { delta: [] } }));
const updateAction = controller.getUpdateAction({
...node,
data: {

View File

@ -1,8 +1,17 @@
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentState } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config';
/**
* outdent node
* 1. if node parent is root, do nothing
* 2. if node parent is not root, move node to after parent and record next sibling ids
* 2.1. if next sibling ids is empty, do nothing
* 2.2. if next sibling ids is not empty
* 2.2.1. if node can add child, move next sibling ids to node's children
* 2.2.2. if node can not add child, move next sibling ids to after node
*/
export const outdentNodeThunk = createAsyncThunk(
'document/outdentNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@ -10,11 +19,40 @@ export const outdentNodeThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const newPrevId = node.parent;
if (!newPrevId) return;
const parent = state.nodes[newPrevId];
const newParentId = parent.parent;
if (!newParentId) return;
await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
const parentId = node.parent;
if (!parentId) return;
const ancestorId = state.nodes[parentId].parent;
if (!ancestorId) return;
const parent = state.nodes[parentId];
const index = state.children[parent.children].indexOf(id);
const nextSiblingIds = state.children[parent.children].slice(index + 1);
const actions = [];
const moveAction = controller.getMoveAction(node, ancestorId, parentId);
actions.push(moveAction);
const config = blockConfig[node.type];
if (nextSiblingIds.length > 0) {
if (config.canAddChild) {
const children = state.children[node.children];
let lastChildId: string | null = null;
const lastIndex = children.length - 1;
if (lastIndex >= 0) {
lastChildId = children[lastIndex];
}
const moveChildrenActions = nextSiblingIds
.reverse()
.map((id) => controller.getMoveAction(state.nodes[id], node.id, lastChildId));
actions.push(...moveChildrenActions);
} else {
const moveChildrenActions = nextSiblingIds
.reverse()
.map((id) => controller.getMoveAction(state.nodes[id], ancestorId, node.id));
actions.push(...moveChildrenActions);
}
}
await controller.applyActions(actions);
}
);

View File

@ -1,16 +1,15 @@
import { DocumentState, TextDelta } from '$app/interfaces/document';
import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '$app_reducers/document/slice';
import { setCursorBeforeThunk } from '../../cursor';
import { newBlock } from '$app/utils/document/blocks/common';
import { blockConfig, SplitRelationship } from '$app/constants/document/config';
import { Editor } from 'slate';
import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
import { ReactEditor } from "slate-react";
export const splitNodeThunk = createAsyncThunk(
'document/splitNode',
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
async (payload: { id: string; editor: ReactEditor; controller: DocumentController }, thunkAPI) => {
const { id, controller, editor } = payload;
// get the split content
const { retain, insert } = getSplitDelta(editor);
@ -68,8 +67,7 @@ export const splitNodeThunk = createAsyncThunk(
await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
// update local node data
dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
ReactEditor.deselect(editor);
// set cursor
await dispatch(setCursorBeforeThunk({ id: newNode.id }));
}

View File

@ -1,44 +1,29 @@
import { TextDelta, NestedBlock, DocumentState, BlockData } from '$app/interfaces/document';
import { TextDelta, DocumentState, BlockData } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '$app_reducers/document/slice';
import { debounce } from '$app/utils/tool';
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta',
async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => {
const { id, delta, controller } = payload;
const { dispatch, getState } = thunkAPI;
const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const isSame = isSameDelta(delta, node.data.delta);
if (isSame) return;
// The block map should be updated immediately
// or the component will use the old data to update the editor
dispatch(documentActions.updateNodeData({ id, data: { delta } }));
const isSame = isSameDelta(delta, node.data.delta || []);
// the transaction is delayed to avoid too many updates
debounceApplyUpdate(controller, {
...node,
data: {
...node.data,
delta,
},
});
if (isSame) return;
const newData = { ...node.data, delta };
await controller.applyActions([
controller.getUpdateAction({
...node,
data: newData,
}),
]);
}
);
const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: NestedBlock) => {
void controller.applyActions([
controller.getUpdateAction({
...updateNode,
data: {
...updateNode.data,
},
}),
]);
}, 500);
export const updateNodeDataThunk = createAsyncThunk<
void,
{
@ -48,14 +33,12 @@ export const updateNodeDataThunk = createAsyncThunk<
}
>('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
const { id, data, controller } = payload;
const { dispatch, getState } = thunkAPI;
const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const newData = { ...node.data, ...data };
dispatch(documentActions.updateNodeData({ id, data: newData }));
await controller.applyActions([
controller.getUpdateAction({
...node,

View File

@ -11,6 +11,7 @@ import {
getStartLineSelectionByOffset,
} from '$app/utils/document/blocks/text/delta';
import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common";
import { ReactEditor } from "slate-react";
export const setCursorBeforeThunk = createAsyncThunk(
'document/setCursorBefore',
@ -39,7 +40,7 @@ export const setCursorAfterThunk = createAsyncThunk(
export const setCursorPreLineThunk = createAsyncThunk(
'document/setCursorPreLine',
async (payload: { id: string; editor: Editor; focusEnd?: boolean }, thunkAPI) => {
async (payload: { id: string; editor: ReactEditor; focusEnd?: boolean }, thunkAPI) => {
const { id, editor, focusEnd } = payload;
const selection = editor.selection as TextSelection;
const { dispatch, getState } = thunkAPI;
@ -73,7 +74,7 @@ export const setCursorPreLineThunk = createAsyncThunk(
export const setCursorNextLineThunk = createAsyncThunk(
'document/setCursorNextLine',
async (payload: { id: string; editor: Editor; focusStart?: boolean }, thunkAPI) => {
async (payload: { id: string; editor: ReactEditor; focusStart?: boolean }, thunkAPI) => {
const { id, editor, focusStart } = payload;
const selection = editor.selection as TextSelection;
const { dispatch, getState } = thunkAPI;

View File

@ -1,3 +1,15 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { DocumentState, NestedBlock } from "$app/interfaces/document";
export * from './cursor';
export * from './blocks';
export * from './turn_to';
export const getBlockByIdThunk = createAsyncThunk<NestedBlock, string>(
'document/getBlockById',
async (id, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as { document: DocumentState };
const node = state.document.nodes[id] as NestedBlock;
return node;
});

View File

@ -0,0 +1,86 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
import { isEqual } from '$app/utils/tool';
const amendAnchorNodeThunk = createAsyncThunk(
'document/amendAnchorNode',
async (
payload: {
id: string;
},
thunkAPI
) => {
const { id } = payload;
const { getState, dispatch } = thunkAPI;
const nodes = (getState() as { document: DocumentState }).document.nodes;
const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
const { anchor: anchorNode, isDragging, focus: focusNode } = range;
if (!isDragging || !anchorNode || anchorNode.id !== id) return;
const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
if (isCollapsed) return;
const selection = anchorNode.selection;
const isForward = selectionIsForward(selection);
const node = nodes[id];
const focus = isForward
? getNodeEndSelection(node.data.delta).anchor
: {
path: [0, 0],
offset: 0,
};
if (isEqual(focus, selection.focus)) return;
const newSelection = {
anchor: selection.anchor,
focus,
};
dispatch(
rangeSelectionActions.setRange({
anchor: {
id,
selection: newSelection as TextSelection,
},
})
);
}
);
export const syncRangeSelectionThunk = createAsyncThunk(
'document/syncRangeSelection',
async (
payload: {
id: string;
selection: TextSelection;
},
thunkAPI
) => {
const { getState, dispatch } = thunkAPI;
const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
const { id, selection } = payload;
const updateRange = {
focus: {
id,
selection,
},
};
const isAnchor = range.anchor?.id === id;
if (isAnchor) {
Object.assign(updateRange, {
anchor: {
id,
selection,
},
});
}
dispatch(rangeSelectionActions.setRange(updateRange));
const anchorId = range.anchor?.id;
if (!isAnchor && anchorId) {
dispatch(amendAnchorNodeThunk({ id: anchorId }));
}
}
);

View File

@ -1,26 +1,22 @@
import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend';
import { combineReducers, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { parseValue, matchChange } from '$app/utils/document/subscribe';
import blockSelection from "$app/components/document/BlockSelection";
import { databaseSlice } from "$app_reducers/database/slice";
const initialState: DocumentState = {
nodes: {},
children: {},
};
const rectSelectionInitialState: {
selections: string[];
} = {
selections: [],
};
const rectSelectionInitialState: string[] = [];
const rangeSelectionInitialState: RangeSelectionState = {};
export const documentSlice = createSlice({
name: 'document',
initialState: initialState,
// Here we can't offer actions to update the document state.
// Because the document state is updated by the `onDataChange`
reducers: {
// initialize the document
clear: () => {
@ -40,22 +36,13 @@ export const documentSlice = createSlice({
state.children = children;
},
// We need this action to update the local state before `onDataChange` to make the UI more smooth,
// because we often use `debounce` to send the change to db, so the db data will be updated later.
updateNodeData: (state, action: PayloadAction<{ id: string; data: Record<string, any> }>) => {
const { id, data } = action.payload;
const node = state.nodes[id];
if (!node) return;
node.data = {
...node.data,
...data,
};
},
// when we use `onDataChange` to handle the change, we don't need care about the change is from which client,
// because the data is always from db state, and then to UI.
// Except the `updateNodeData` action, we will use it before `onDataChange` to update the local state,
// so we should skip update block's `data` field when the change is from local
/**
This function listens for changes in the data layer triggered by the data API,
and updates the UI state accordingly.
It enables a unidirectional data flow,
where changes in the data layer update the UI layer,
but not the other way around.
*/
onDataChange: (
state,
action: PayloadAction<{
@ -64,52 +51,49 @@ export const documentSlice = createSlice({
}>
) => {
const { path, id, value, command } = action.payload.data;
const isRemote = action.payload.isRemote;
const valueJson = parseValue(value);
if (!valueJson) return;
// match change
matchChange(state, { path, id, value: valueJson, command }, isRemote);
matchChange(state, { path, id, value: valueJson, command });
},
},
});
export const rectSelectionSlice = createSlice({
name: 'rectSelection',
name: 'documentRectSelection',
initialState: rectSelectionInitialState,
reducers: {
// update block selections
updateSelections: (state, action: PayloadAction<string[]>) => {
state.selections = action.payload;
return action.payload;
},
// set block selected
setSelectionById: (state, action: PayloadAction<string>) => {
const id = action.payload;
state.selections = [id];
if (state.includes(id)) return;
state.push(id);
},
}
},
});
export const rangeSelectionSlice = createSlice({
name: 'rangeSelection',
name: 'documentRangeSelection',
initialState: rangeSelectionInitialState,
reducers: {
setRange: (
state,
action: PayloadAction<RangeSelectionState>
) => {
state.anchor = action.payload.anchor;
state.focus = action.payload.focus;
setRange: (state, action: PayloadAction<RangeSelectionState>) => {
return {
...state,
...action.payload,
};
},
clearRange: (state, _: PayloadAction) => {
state.anchor = undefined;
state.focus = undefined;
return rangeSelectionInitialState;
},
}
},
});
export const documentReducers = {
@ -120,4 +104,4 @@ export const documentReducers = {
export const documentActions = documentSlice.actions;
export const rectSelectionActions = rectSelectionSlice.actions;
export const rangeSelectionActions = rangeSelectionSlice.actions;
export const rangeSelectionActions = rangeSelectionSlice.actions;

View File

@ -5,13 +5,13 @@ import {
NestedBlock,
RangeSelectionState,
TextDelta,
TextSelection
} from "$app/interfaces/document";
TextSelection,
} from '$app/interfaces/document';
import { Descendant, Element, Text } from 'slate';
import { BlockPB } from '@/services/backend';
import { Log } from '$app/utils/log';
import { nanoid } from 'nanoid';
import { clone } from "$app/utils/tool";
import { clone } from '$app/utils/tool';
export function slateValueToDelta(slateNodes: Descendant[]) {
const element = slateNodes[0] as Element;
@ -145,10 +145,35 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState {
const point = {
id,
selection
selection,
};
return {
anchor: clone(point),
focus: clone(point),
isDragging: false,
};
}
export function nodeInRange(
id: string,
range: {
startId: string;
endId: string;
},
isForward: boolean,
document: DocumentState
) {
const { startId, endId } = range;
let currentId = startId;
while (currentId && currentId !== id && currentId !== endId) {
if (isForward) {
currentId = getNextLineId(document, currentId) || '';
} else {
currentId = getPrevLineId(document, currentId) || '';
}
}
}
if (currentId === id) {
return true;
}
return false;
}

View File

@ -80,6 +80,13 @@ export function getNodeBeginSelection(): TextSelection {
return selection;
}
export function getEditorEndPoint(editor: Editor): SelectionPoint {
const fragment = (editor.children[0] as Element).children;
const lastIndex = fragment.length - 1;
const lastNode = fragment[lastIndex] as Text;
return { path: [0, lastIndex], offset: lastNode.text.length };
}
/**
* get the selection of the end of the node
* @param delta
@ -282,3 +289,9 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
const beginPoint = getPointByTextOffset(delta, lineBeginOffset);
return beginPoint;
}
export function selectionIsForward(selection: TextSelection) {
const { anchor, focus } = selection;
if (!anchor || !focus) return false;
return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);
}

View File

@ -2,6 +2,7 @@ import { DeltaTypePB } from "@/services/backend/models/flowy-document2";
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from "$app/interfaces/document";
import { Log } from "../log";
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from "$app/constants/document/block";
import { isEqual } from "$app/utils/tool";
// This is a list of all the possible changes that can happen to document data
const matchCases = [
@ -26,12 +27,11 @@ export function matchChange(
id: string;
value: BlockPBValue & string[];
},
isRemote?: boolean
) {
const matchCase = matchCases.find((item) => item.match(command, path));
if (matchCase) {
matchCase.onMatch(state, id, value, isRemote);
matchCase.onMatch(state, id, value);
}
}
@ -99,46 +99,34 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) {
);
}
function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
state.nodes[blockId] = blockChangeValue2Node(blockValue);
}
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) {
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
const block = blockChangeValue2Node(blockValue);
const node = state.nodes[blockId];
if (!node) return;
// if the change is from remote, we should update all fields
if (isRemote) {
state.nodes[blockId] = block;
return;
}
// if the change is from local, we should update all fields except `data`,
// because we will update `data` field in `updateNodeData` action
const shouldUpdate = node.parent !== block.parent || node.type !== block.type || node.children !== block.children;
if (shouldUpdate) {
state.nodes[blockId] = {
...block,
data: node.data,
};
}
if (isEqual(node, block)) return;
state.nodes[blockId] = block;
return;
}
function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue, _isRemote?: boolean) {
function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue) {
delete state.nodes[blockId];
}
function onMatchChildrenInsert(state: DocumentState, id: string, children: string[], _isRemote?: boolean) {
function onMatchChildrenInsert(state: DocumentState, id: string, children: string[]) {
state.children[id] = children;
}
function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[], _isRemote?: boolean) {
function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) {
const children = state.children[id];
if (!children) return;
state.children[id] = newChildren;
}
function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[], _isRemote?: boolean) {
function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[]) {
delete state.children[id];
}

View File

@ -1,11 +1,15 @@
export function debounce(fn: (...args: any[]) => void, delay: number) {
let timeout: NodeJS.Timeout;
return (...args: any[]) => {
const debounceFn = (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
fn.apply(undefined, args);
}, delay);
};
debounceFn.cancel = () => {
clearTimeout(timeout);
};
return debounceFn;
}
export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) {
@ -97,4 +101,4 @@ export function clone<T>(value: T): T {
result[key] = clone(value[key]);
}
return result;
}
}

View File

@ -20,6 +20,11 @@ body {
@apply bg-[#E0F8FF]
}
#appflowy-block-doc ::selection {
@apply bg-[transparent]
}
.btn {
@apply rounded-xl border border-gray-500 px-4 py-3;
}