mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
f9862c501c
commit
99c48f7100
@ -24,6 +24,7 @@ export function useHoveringToolbar(id: string) {
|
||||
el.style.left = position.left;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
inFocus,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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>) {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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} />}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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];
|
||||
|
@ -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]);
|
||||
}
|
||||
);
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
@ -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 }));
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
});
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
);
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user