diff --git a/frontend/appflowy_tauri/.eslintrc.cjs b/frontend/appflowy_tauri/.eslintrc.cjs index dfc0cc41f8..d71b96db03 100644 --- a/frontend/appflowy_tauri/.eslintrc.cjs +++ b/frontend/appflowy_tauri/.eslintrc.cjs @@ -51,6 +51,12 @@ module.exports = { 'no-void': 'off', 'prefer-const': 'warn', 'prefer-spread': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + } + ], }, ignorePatterns: ['src/**/*.test.ts'], }; diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index 6f14d9bc50..a428f94141 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -22,7 +22,6 @@ "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.12", "@reduxjs/toolkit": "^1.9.2", - "@slate-yjs/core": "^0.3.1", "@tanstack/react-virtual": "3.0.0-beta.54", "@tauri-apps/api": "^1.2.0", "dayjs": "^1.11.7", diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index 7b5c821eb6..8ef1eba8f3 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -22,9 +22,6 @@ dependencies: '@reduxjs/toolkit': specifier: ^1.9.2 version: 1.9.3(react-redux@8.0.5)(react@18.2.0) - '@slate-yjs/core': - specifier: ^0.3.1 - version: 0.3.1(slate@0.91.4)(yjs@13.5.51) '@tanstack/react-virtual': specifier: 3.0.0-beta.54 version: 3.0.0-beta.54(react@18.2.0) @@ -1412,17 +1409,6 @@ packages: '@sinonjs/commons': 2.0.0 dev: false - /@slate-yjs/core@0.3.1(slate@0.91.4)(yjs@13.5.51): - resolution: {integrity: sha512-8nvS9m5FhMNONgydAfzwDCUhuoWbgzx5Bvw1/foSe+JO331UOT1xAKbUX5FzGCOunUcbRjMPXSdNyiPc0dodJg==} - peerDependencies: - slate: '>=0.70.0' - yjs: ^13.5.29 - dependencies: - slate: 0.91.4 - y-protocols: 1.0.5 - yjs: 13.5.51 - dev: false - /@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0): resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==} peerDependencies: @@ -5133,12 +5119,6 @@ packages: yjs: 13.5.51 dev: false - /y-protocols@1.0.5: - resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==} - dependencies: - lib0: 0.2.73 - dev: false - /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts index ceaeccbc90..17195001aa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts @@ -1,4 +1,4 @@ -import { documentActions } from '@/appflowy_app/stores/reducers/document/slice'; +import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice"; import { useAppDispatch } from '@/appflowy_app/stores/store'; import { useRef, useState, useEffect } from 'react'; @@ -12,7 +12,7 @@ export function useBlockMenu(nodeId: string, open: boolean) { return; } // set selection when open - dispatch(documentActions.setSelectionById(nodeId)); + dispatch(rectSelectionActions.setSelectionById(nodeId)); // get node rect const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect(); if (!rect) return; @@ -21,7 +21,7 @@ export function useBlockMenu(nodeId: string, open: boolean) { top: rect.top + 'px', left: rect.left + 'px', }); - }, [open, nodeId]); + }, [open, nodeId, dispatch]); return { ref, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx index 565fa04d04..359b5763bf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppDispatch } from '$app/stores/store'; -import { documentActions } from '@/appflowy_app/stores/reducers/document/slice'; +import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice"; +import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks'; +import { setRectSelectionThunk } from "$app_reducers/document/async-actions/rect_selection"; export function useBlockSelection({ container, @@ -13,12 +15,13 @@ export function useBlockSelection({ const disaptch = useAppDispatch(); const [isDragging, setDragging] = useState(false); - const pointRef = useRef([]); - const startScrollTopRef = useRef(0); + const startPointRef = useRef([]); + + const { getIntersectedBlockIds } = useNodesRect(container); useEffect(() => { onDragging?.(isDragging); - }, [isDragging]); + }, [isDragging, onDragging]); const [rect, setRect] = useState<{ startX: number; @@ -40,7 +43,7 @@ export function useBlockSelection({ width: width + 'px', height: height + 'px', }; - }, [rect]); + }, [container.scrollLeft, container.scrollTop, rect]); const isPointInBlock = useCallback((target: HTMLElement | null) => { let node = target; @@ -53,48 +56,45 @@ export function useBlockSelection({ return false; }, []); - const handleDragStart = useCallback((e: MouseEvent) => { - if (isPointInBlock(e.target as HTMLElement)) { - return; - } - e.preventDefault(); - setDragging(true); + const handleDragStart = useCallback( + (e: MouseEvent) => { + if (isPointInBlock(e.target as HTMLElement)) { + return; + } + e.preventDefault(); + setDragging(true); - const startX = e.clientX + container.scrollLeft; - const startY = e.clientY + container.scrollTop; - pointRef.current = [startX, startY]; - startScrollTopRef.current = container.scrollTop; - setRect({ - startX, - startY, - endX: startX, - endY: startY, - }); - }, []); + const startX = e.clientX + container.scrollLeft; + const startY = e.clientY + container.scrollTop; + startPointRef.current = [startX, startY]; + setRect({ + startX, + startY, + endX: startX, + endY: startY, + }); + }, + [container.scrollLeft, container.scrollTop, isPointInBlock] + ); const updateSelctionsByPoint = useCallback( (clientX: number, clientY: number) => { if (!isDragging) return; - const [startX, startY] = pointRef.current; + const [startX, startY] = startPointRef.current; const endX = clientX + container.scrollLeft; const endY = clientY + container.scrollTop; - setRect({ + const newRect = { startX, startY, endX, endY, - }); - disaptch( - documentActions.setSelectionByRect({ - startX: Math.min(startX, endX), - startY: Math.min(startY, endY), - endX: Math.max(startX, endX), - endY: Math.max(startY, endY), - }) - ); + }; + const blockIds = getIntersectedBlockIds(newRect); + setRect(newRect); + disaptch(setRectSelectionThunk(blockIds)); }, - [isDragging] + [container.scrollLeft, container.scrollTop, disaptch, getIntersectedBlockIds, isDragging] ); const handleDraging = useCallback( @@ -113,13 +113,13 @@ export function useBlockSelection({ container.scrollBy(0, delta); } }, - [isDragging] + [container, isDragging, updateSelctionsByPoint] ); const handleDragEnd = useCallback( (e: MouseEvent) => { if (isPointInBlock(e.target as HTMLElement) && !isDragging) { - disaptch(documentActions.updateSelections([])); + disaptch(rectSelectionActions.updateSelections([])); return; } if (!isDragging) return; @@ -128,21 +128,21 @@ export function useBlockSelection({ setDragging(false); setRect(null); }, - [isDragging] + [disaptch, isDragging, isPointInBlock, updateSelctionsByPoint] ); useEffect(() => { if (!ref.current) return; - container.addEventListener('mousedown', handleDragStart); - container.addEventListener('mousemove', handleDraging); - container.addEventListener('mouseup', handleDragEnd); + document.addEventListener('mousedown', handleDragStart); + document.addEventListener('mousemove', handleDraging); + document.addEventListener('mouseup', handleDragEnd); return () => { - container.removeEventListener('mousedown', handleDragStart); - container.removeEventListener('mousemove', handleDraging); - container.removeEventListener('mouseup', handleDragEnd); + document.removeEventListener('mousedown', handleDragStart); + document.removeEventListener('mousemove', handleDraging); + document.removeEventListener('mouseup', handleDragEnd); }; - }, [handleDragStart, handleDragEnd, handleDraging, container]); + }, [handleDragStart, handleDragEnd, handleDraging]); return { isDragging, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts new file mode 100644 index 0000000000..a4ac5e75f9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts @@ -0,0 +1,76 @@ +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { useAppSelector } from '$app/stores/store'; +import { RegionGrid } from '$app/utils/region_grid'; + +export function useNodesRect(container: HTMLDivElement) { + const controller = useContext(DocumentControllerContext); + + const data = useAppSelector((state) => { + return state.document; + }); + + const regionGrid = useMemo(() => { + if (!controller) return null; + return new RegionGrid(300); + }, [controller]); + + const updateNodeRect = useCallback( + (node: Element) => { + const { x, y, width, height } = node.getBoundingClientRect(); + const id = node.getAttribute('data-block-id'); + if (!id) return; + const rect = { + id, + x: x + container.scrollLeft, + y: y + container.scrollTop, + width, + height, + }; + regionGrid?.updateBlock(rect); + }, + [container.scrollLeft, container.scrollTop, regionGrid] + ); + + const updateViewPortNodesRect = useCallback(() => { + const nodes = container.querySelectorAll('[data-block-id]'); + nodes.forEach(updateNodeRect); + }, [container, updateNodeRect]); + + // update nodes rect when data changed + useEffect(() => { + updateViewPortNodesRect(); + }, [data, updateViewPortNodesRect]); + + // update nodes rect when scroll + useEffect(() => { + container.addEventListener('scroll', updateViewPortNodesRect); + return () => { + container.removeEventListener('scroll', updateViewPortNodesRect); + }; + }, [container, updateViewPortNodesRect]); + + const getIntersectedBlockIds = useCallback( + (rect: { startX: number; startY: number; endX: number; endY: number }) => { + if (!regionGrid) return []; + const { startX, startY, endX, endY } = rect; + const x = Math.min(startX, endX); + const y = Math.min(startY, endY); + const width = Math.abs(endX - startX); + const height = Math.abs(endY - startY); + return regionGrid + .getIntersectingBlocks({ + x, + y, + width, + height, + }) + .map((block) => block.id); + }, + [regionGrid] + ); + + return { + getIntersectedBlockIds, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index e319e484fd..455c65b338 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx @@ -24,6 +24,7 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) { onMouseDown={(e) => { // prevent toolbar from taking focus away from editor e.preventDefault(); + e.stopPropagation(); }} > handleToggleMenu(true)} sx={sx}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts index 0055de1bd8..33e5ec4921 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts @@ -14,7 +14,7 @@ export function useCodeBlock(node: NestedBlock) { const id = node.id; const dispatch = useAppDispatch(); const controller = useContext(DocumentControllerContext); - const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id); + const { editor, ...rest } = useTextInput(id); const defaultTextInputEvents = useDefaultTextInputEvents(id); const customEvents = useMemo(() => { @@ -81,8 +81,6 @@ export function useCodeBlock(node: NestedBlock) { return { editor, onKeyDown, - onChange, - value, - onDOMBeforeInput, + ...rest }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx index d698e422e4..f94d2f3873 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx @@ -12,7 +12,7 @@ export default function CodeBlock({ placeholder, ...props }: { node: NestedBlock; placeholder?: string } & React.HTMLAttributes) { - const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useCodeBlock(node); + const { editor, value, onChange, ...rest } = useCodeBlock(node); const className = props.className ? ` ${props.className}` : ''; const id = node.id; @@ -24,11 +24,9 @@ export default function CodeBlock({ - decorateCodeFunc(entry, language)} - onDOMBeforeInput={onDOMBeforeInput} renderLeaf={CodeLeaf} renderElement={CodeBlockElement} placeholder={placeholder || 'Please enter some text...'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts index b4739699f6..89b05cb975 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts @@ -1,32 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { useSubscribeNode } from '../_shared/SubscribeNode.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import { documentActions } from '$app/stores/reducers/document/slice'; export function useNode(id: string) { const { node, childIds, isSelected } = useSubscribeNode(id); const ref = useRef(null); - const dispatch = useAppDispatch(); - - useEffect(() => { - if (!ref.current) return; - const rect = ref.current.getBoundingClientRect(); - - const scrollContainer = document.querySelector('.doc-scroller-container') as HTMLDivElement; - dispatch( - documentActions.updateNodePosition({ - id, - rect: { - x: rect.x, - y: rect.y + scrollContainer.scrollTop, - height: rect.height, - width: rect.width, - }, - }) - ); - }, []); - return { ref, node, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts index d06b200d61..01552bc85e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts @@ -2,14 +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, onChange, value, onDOMBeforeInput } = useTextInput(id); + const { editor, ...rest } = + useTextInput(id); const { onKeyDown } = useTextBlockKeyEvent(id, editor); return { - onChange, onKeyDown, - onDOMBeforeInput, editor, - value, + ...rest }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx index 4af1c87637..b3c7e8e0af 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx @@ -16,7 +16,12 @@ function TextBlock({ childIds?: string[]; placeholder?: string; } & React.HTMLAttributes) { - const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useTextBlock(node.id); + const { + editor, + value, + onChange, + ...rest + } = useTextBlock(node.id); const className = props.className !== undefined ? ` ${props.className}` : ''; return ( @@ -25,8 +30,7 @@ function TextBlock({ } placeholder={placeholder || 'Please enter some text...'} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx index 4a5c917b3c..9947f31bbd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx @@ -31,6 +31,7 @@ export default function VirtualizedList({ > {node && childIds && virtualItems.length ? (
((state) => { - return state.document.selections?.includes(id) || false; + return state.rectSelection.selections?.includes(id) || false; }); // Memoize the node and its children @@ -27,7 +27,7 @@ export function useSubscribeNode(id: string) { // It very important for performance const memoizedNode = useMemo( () => node, - [node?.id, JSON.stringify(node?.data), node?.parent, node?.type, node?.children] + [JSON.stringify(node)] ); const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts index a1db7e74d0..8194fbe51f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts @@ -1,7 +1,5 @@ -import { createEditor, Descendant, Transforms } from 'slate'; +import { createEditor, Descendant, Transforms, Element, Text, Editor } from 'slate'; import { ReactEditor, withReact } from 'slate-react'; -import * as Y from 'yjs'; -import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; @@ -9,15 +7,16 @@ import { TextDelta, TextSelection } from '$app/interfaces/document'; import { NodeContext } from '../SubscribeNode.hooks'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update'; -import { deltaToSlateValue } from '$app/utils/document/blocks/common'; -import { documentActions } from '$app_reducers/document/slice'; - -import { isSameDelta } from '$app/utils/document/blocks/text/delta'; +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'; export function useTextInput(id: string) { - const dispatch = useAppDispatch(); + const [editor] = useState(() => withReact(createEditor())); const node = useContext(NodeContext); - const selectionRef = useRef(null); + const { sendDelta } = useController(id); + const { storeSelection } = useSelection(id, editor); + const isComposition = useRef(false); const delta = useMemo(() => { if (!node || !('delta' in node.data)) { @@ -25,62 +24,40 @@ export function useTextInput(id: string) { } return node.data.delta; }, [node]); + const [value, setValue] = useState(deltaToSlateValue(delta)); - const { editor, yText } = useBindYjs(id, delta); + // Update the editor's value when the node's delta changes. + useEffect(() => { + // If composition is in progress, do nothing. + if (isComposition.current) return; - const [value, setValue] = useState([]); + // 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 storeSelection = useCallback(() => { - if (!ReactEditor.isFocused(editor)) { - selectionRef.current = null; - return; - } - - const selection = editor.selection as TextSelection; - if (selectionRef.current && JSON.stringify(selection) !== JSON.stringify(selectionRef.current)) { - Transforms.select(editor, selectionRef.current); - selectionRef.current = null; - } - - dispatch(documentActions.setTextSelection({ blockId: id, selection })); - }, [dispatch, editor, id]); - - const currentSelection = useAppSelector((state) => state.document.textSelections[id]); - const restoreSelection = useCallback(() => { - if (!currentSelection) return; - if (ReactEditor.isFocused(editor)) { - Transforms.select(editor, currentSelection); - } else { - selectionRef.current = currentSelection; - Transforms.select(editor, currentSelection); - ReactEditor.focus(editor); - } - }, [currentSelection, editor]); + const slateValue = deltaToSlateValue(delta); + editor.children = slateValue; + setValue(slateValue); + }, [delta, editor]); + // 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 composition is in progress, do nothing. + if (isComposition.current) return; + + // Update the node's delta + const textDelta = slateValueToDelta(e); + void sendDelta(textDelta); }, - [storeSelection] + [sendDelta, storeSelection] ); - useEffect(() => { - restoreSelection(); - return () => { - dispatch(documentActions.removeTextSelection(id)); - }; - }, [dispatch, id, restoreSelection]); - - 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); - } - } - const onDOMBeforeInput = useCallback((e: InputEvent) => { // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition". // It will cause repeated characters when inputting Chinese. @@ -90,73 +67,28 @@ export function useTextInput(id: string) { } }, []); + const onCompositionStart = useCallback(() => { + isComposition.current = true; + }, []); + + const onCompositionUpdate = useCallback(() => { + isComposition.current = true; + }, []); + + const onCompositionEnd = useCallback(() => { + isComposition.current = false; + }, []); + return { editor, - yText, onChange, value, onDOMBeforeInput, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd, }; } -function useBindYjs(id: string, delta: TextDelta[]) { - const { sendDelta } = useController(id); - const yTextRef = useRef(); - - // Create a yjs document and get the shared type - const sharedType = useMemo(() => { - const doc = new Y.Doc(); - const _sharedType = doc.get('content', Y.XmlText) as Y.XmlText; - - const insertDelta = slateNodesToInsertDelta(deltaToSlateValue(delta)); - // Load the initial value into the yjs document - _sharedType.applyDelta(insertDelta); - - const yText = insertDelta[0].insert as Y.XmlText; - yTextRef.current = yText; - - return _sharedType; - // Here we only want to create the sharedType once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []); - - useEffect(() => { - YjsEditor.connect(editor); - return () => { - yTextRef.current = undefined; - YjsEditor.disconnect(editor); - }; - }, [editor]); - - useEffect(() => { - const yText = yTextRef.current; - if (!yText) return; - const textEventHandler = (event: Y.YTextEvent) => { - const textDelta = event.target.toDelta(); - void sendDelta(textDelta); - }; - - yText.observe(textEventHandler); - return () => { - yText.unobserve(textEventHandler); - }; - }, [sendDelta]); - - useEffect(() => { - const yText = yTextRef.current; - if (!yText) return; - - // If the delta is not equal to the current yText, then we need to update the yText - const isSame = isSameDelta(delta, yText.toDelta()); - if (isSame) return; - - yText.delete(0, yText.length); - yText.applyDelta(delta); - }, [delta, editor]); - - return { editor, yText: yTextRef.current }; -} function useController(id: string) { const docController = useContext(DocumentControllerContext); @@ -180,3 +112,96 @@ function useController(id: string) { sendDelta, }; } + +function useSelection(id: string, editor: ReactEditor) { + const dispatch = useAppDispatch(); + const selectionRef = useRef(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; + }); + + // 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; + }, + [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 { + storeSelection, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index a13388f476..0085fd045d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -1,4 +1,5 @@ import { Editor } from 'slate'; +import { RegionGrid } from '$app/utils/region_grid'; export enum BlockType { PageBlock = 'page', @@ -127,10 +128,17 @@ export interface DocumentState { nodes: Record; // map of block id to children block ids children: Record; - // selected block ids - selections: string[]; - // map of block id to text selection - textSelections: Record; +} + +export interface RangeSelectionState { + anchor?: PointState, + focus?: PointState, +} + + +export interface PointState { + id: string, + selection: TextSelection } export enum ChangeType { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts index 804a03bb69..f99d922eb9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts @@ -24,11 +24,11 @@ export class DocumentController { private readonly observer: DocumentObserver; constructor( - public readonly viewId: string, + public readonly documentId: string, private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void ) { - this.backendService = new DocumentBackendService(viewId); - this.observer = new DocumentObserver(viewId); + this.backendService = new DocumentBackendService(documentId); + this.observer = new DocumentObserver(documentId); } create = async (): Promise => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts index 982a9c992f..506dd1424f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts @@ -1,9 +1,8 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentState } from '$app/interfaces/document'; -import { getPrevLineId } from '$app/utils/document/blocks/common'; -import { setCursorAfterThunk } from '$app_reducers/document/async-actions'; -import { documentActions } from '$app_reducers/document/slice'; +import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common"; +import { documentActions, rangeSelectionActions } from "$app_reducers/document/slice"; import { blockConfig } from '$app/constants/document/config'; import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta'; @@ -80,6 +79,7 @@ export const mergeToPrevLineThunk = createAsyncThunk( await controller.applyActions(actions); // set cursor after the prev line - dispatch(documentActions.setTextSelection({ blockId: prevLine.id, selection })); + const range = getCollapsedRange(prevLine.id, selection); + dispatch(rangeSelectionActions.setRange(range)); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts index ffe75a2afc..20ec3c33da 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts @@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro 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) => { @@ -10,6 +11,8 @@ export const updateNodeDeltaThunk = createAsyncThunk( const { dispatch, 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 } })); @@ -34,7 +37,7 @@ const debounceApplyUpdate = debounce((controller: DocumentController, updateNode }, }), ]); -}, 200); +}, 500); export const updateNodeDataThunk = createAsyncThunk< void, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts index 46d4111b69..571564c286 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts @@ -1,5 +1,5 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions } from '../slice'; +import { rangeSelectionActions } from "../slice"; import { DocumentState, TextSelection } from '$app/interfaces/document'; import { Editor } from 'slate'; import { @@ -10,7 +10,7 @@ import { getNodeEndSelection, getStartLineSelectionByOffset, } from '$app/utils/document/blocks/text/delta'; -import { getNextLineId, getPrevLineId } from '$app/utils/document/blocks/common'; +import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common"; export const setCursorBeforeThunk = createAsyncThunk( 'document/setCursorBefore', @@ -18,7 +18,9 @@ export const setCursorBeforeThunk = createAsyncThunk( const { id } = payload; const { dispatch } = thunkAPI; const selection = getNodeBeginSelection(); - dispatch(documentActions.setTextSelection({ blockId: id, selection })); + + const range = getCollapsedRange(id, selection); + dispatch(rangeSelectionActions.setRange(range)); } ); @@ -30,7 +32,8 @@ export const setCursorAfterThunk = createAsyncThunk( const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; const selection = getNodeEndSelection(node.data.delta); - dispatch(documentActions.setTextSelection({ blockId: node.id, selection })); + const range = getCollapsedRange(id, selection); + dispatch(rangeSelectionActions.setRange(range)); } ); @@ -64,7 +67,7 @@ export const setCursorPreLineThunk = createAsyncThunk( // set the cursor to prev line with the relative offset const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset); - dispatch(documentActions.setTextSelection({ blockId: prevLineNode.id, selection: newSelection })); + dispatch(rangeSelectionActions.setRange(getCollapsedRange(prevLineNode.id, newSelection))); } ); @@ -100,6 +103,6 @@ export const setCursorNextLineThunk = createAsyncThunk( // set the cursor to next line with the relative offset const newSelection = getStartLineSelectionByOffset(delta, textOffset); - dispatch(documentActions.setTextSelection({ blockId: nextLineNode.id, selection: newSelection })); + dispatch(rangeSelectionActions.setRange(getCollapsedRange(nextLineNode.id, newSelection))); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts new file mode 100644 index 0000000000..e8e8acc1fe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts @@ -0,0 +1,27 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { getNextNodeId, getPrevNodeId } from "$app/utils/document/blocks/common"; +import { DocumentState } from "$app/interfaces/document"; +import { rectSelectionActions } from "$app_reducers/document/slice"; + +export const setRectSelectionThunk = createAsyncThunk( + 'document/setRectSelection', + async (payload: string[], thunkAPI) => { + const { getState, dispatch } = thunkAPI; + const documentState = (getState() as { document: DocumentState }).document; + const selected: Record = {}; + payload.forEach((id) => { + const node = documentState.nodes[id]; + if (!node.parent) { + return; + } + selected[id] = selected[id] === undefined ? true : selected[id]; + selected[node.parent] = false; + const nextNodeId = getNextNodeId(documentState, node.parent); + const prevNodeId = getPrevNodeId(documentState, node.parent); + if ((nextNodeId && payload.includes(nextNodeId)) || (prevNodeId && payload.includes(prevNodeId))) { + selected[node.parent] = true; + } + }); + dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id]))) + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index 71648bc85d..0f6c7c8eab 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -1,18 +1,23 @@ -import { DocumentState, Node, TextSelection } from '@/appflowy_app/interfaces/document'; +import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document'; import { BlockEventPayloadPB } from '@/services/backend'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { RegionGrid } from '@/appflowy_app/utils/region_grid'; +import { combineReducers, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { parseValue, matchChange } from '$app/utils/document/subscribe'; - -const regionGrid = new RegionGrid(50); +import blockSelection from "$app/components/document/BlockSelection"; +import { databaseSlice } from "$app_reducers/database/slice"; const initialState: DocumentState = { nodes: {}, children: {}, - selections: [], - textSelections: {}, }; +const rectSelectionInitialState: { + selections: string[]; +} = { + selections: [], +}; + +const rangeSelectionInitialState: RangeSelectionState = {}; + export const documentSlice = createSlice({ name: 'document', initialState: initialState, @@ -35,81 +40,6 @@ export const documentSlice = createSlice({ state.children = children; }, - // update block selections - updateSelections: (state, action: PayloadAction) => { - state.selections = action.payload; - }, - - // set block selected - setSelectionById: (state, action: PayloadAction) => { - const id = action.payload; - state.selections = [id]; - }, - - // set block selected by selection rect - setSelectionByRect: ( - state, - action: PayloadAction<{ - startX: number; - startY: number; - endX: number; - endY: number; - }> - ) => { - const { startX, startY, endX, endY } = action.payload; - const blocks = regionGrid.getIntersectBlocks(startX, startY, endX, endY); - state.selections = blocks.map((block) => block.id); - }, - - // update block position - updateNodePosition: ( - state, - action: PayloadAction<{ - id: string; - rect: { - x: number; - y: number; - width: number; - height: number; - }; - }> - ) => { - const { id, rect } = action.payload; - const position = { - id, - ...rect, - }; - regionGrid.updateBlock(id, position); - }, - - // update text selections - setTextSelection: ( - state, - action: PayloadAction<{ - blockId: string; - selection?: TextSelection; - }> - ) => { - const { blockId, selection } = action.payload; - const node = state.nodes[blockId]; - const oldSelection = state.textSelections[blockId]; - if (JSON.stringify(oldSelection) === JSON.stringify(selection)) return; - if (!node || !selection) { - delete state.textSelections[blockId]; - } else { - state.textSelections = { - [blockId]: selection, - }; - } - }, - - // remove text selections - removeTextSelection: (state, action: PayloadAction) => { - const id = action.payload; - if (!state.textSelections[id]) return; - state.textSelections; - }, - // 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 }>) => { @@ -145,4 +75,49 @@ export const documentSlice = createSlice({ }, }); +export const rectSelectionSlice = createSlice({ + name: 'rectSelection', + initialState: rectSelectionInitialState, + reducers: { + // update block selections + updateSelections: (state, action: PayloadAction) => { + state.selections = action.payload; + }, + + // set block selected + setSelectionById: (state, action: PayloadAction) => { + const id = action.payload; + state.selections = [id]; + }, + } +}); + + +export const rangeSelectionSlice = createSlice({ + name: 'rangeSelection', + initialState: rangeSelectionInitialState, + reducers: { + setRange: ( + state, + action: PayloadAction + ) => { + state.anchor = action.payload.anchor; + state.focus = action.payload.focus; + }, + + clearRange: (state, _: PayloadAction) => { + state.anchor = undefined; + state.focus = undefined; + }, + } +}); + +export const documentReducers = { + [documentSlice.name]: documentSlice.reducer, + [rectSelectionSlice.name]: rectSelectionSlice.reducer, + [rangeSelectionSlice.name]: rangeSelectionSlice.reducer, +}; + export const documentActions = documentSlice.actions; +export const rectSelectionActions = rectSelectionSlice.actions; +export const rangeSelectionActions = rangeSelectionSlice.actions; \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts index be47237dd1..03c81a6427 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts @@ -14,7 +14,7 @@ import { currentUserSlice } from './reducers/current-user/slice'; import { gridSlice } from './reducers/grid/slice'; import { workspaceSlice } from './reducers/workspace/slice'; import { databaseSlice } from './reducers/database/slice'; -import { documentSlice } from './reducers/document/slice'; +import { documentReducers } from './reducers/document/slice'; import { boardSlice } from './reducers/board/slice'; import { errorSlice } from './reducers/error/slice'; import { activePageIdSlice } from '$app_reducers/active-page-id/slice'; @@ -33,9 +33,9 @@ const store = configureStore({ [gridSlice.name]: gridSlice.reducer, [databaseSlice.name]: databaseSlice.reducer, [boardSlice.name]: boardSlice.reducer, - [documentSlice.name]: documentSlice.reducer, [workspaceSlice.name]: workspaceSlice.reducer, [errorSlice.name]: errorSlice.reducer, + ...documentReducers, }, middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware), }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts index c3af7c1c37..2ff3ca0d64 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts @@ -1,8 +1,29 @@ -import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document'; +import { + BlockData, + BlockType, + DocumentState, + NestedBlock, + RangeSelectionState, + TextDelta, + 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"; + +export function slateValueToDelta(slateNodes: Descendant[]) { + const element = slateNodes[0] as Element; + const children = element.children as Text[]; + return children.map((child) => { + const { text, ...attributes } = child; + return { + insert: text, + attributes, + }; + }); +} export function deltaToSlateValue(delta: TextDelta[]) { const slateNode = { @@ -101,6 +122,16 @@ export function getNextNodeId(state: DocumentState, id: string) { return nextNodeId; } +export function getPrevNodeId(state: DocumentState, id: string) { + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + const children = state.children[parent.children]; + const index = children.indexOf(id); + const prevNodeId = children[index - 1]; + return prevNodeId; +} + export function newBlock(type: BlockType, parentId: string, data: BlockData): NestedBlock { return { id: generateId(), @@ -110,3 +141,14 @@ export function newBlock(type: BlockType, parentId: string, data: BlockDat data, }; } + +export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState { + const point = { + id, + selection + }; + return { + anchor: clone(point), + focus: clone(point), + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts index aabc8eacbe..1bd5bfc60a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts @@ -1,7 +1,7 @@ -import { DeltaTypePB } from '@/services/backend/models/flowy-document2'; -import { BlockType, NestedBlock, DocumentState, ChangeType, BlockPBValue } from '../../interfaces/document'; -import { Log } from '../log'; -import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '../../constants/document/block'; +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"; // This is a list of all the possible changes that can happen to document data const matchCases = [ @@ -100,8 +100,7 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) { } function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) { - const block = blockChangeValue2Node(blockValue); - state.nodes[blockId] = block; + state.nodes[blockId] = blockChangeValue2Node(blockValue); } function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) { @@ -125,12 +124,7 @@ function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: B return; } -function onMatchBlockDelete(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) { - const index = state.selections.indexOf(blockId); - if (index > -1) { - state.selections.splice(index, 1); - } - delete state.textSelections[blockId]; +function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue, _isRemote?: boolean) { delete state.nodes[blockId]; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts index 5b743e48ea..3922c67329 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts @@ -5,92 +5,100 @@ export interface BlockPosition { height: number; width: number; } -interface BlockRegion { - regionX: number; - regionY: number; - blocks: BlockPosition[]; + +interface Rectangle { + x: number; + y: number; + height: number; + width: number; } export class RegionGrid { - private regions: BlockRegion[][]; - private regionSize: number; - private blocks = new Map(); + private readonly gridSize: number; + private readonly grid: Map; + private readonly blockKeysMap: Map; - constructor(regionSize: number) { - this.regionSize = regionSize; - this.regions = []; + constructor(gridSize: number) { + this.gridSize = gridSize; + this.grid = new Map(); + this.blockKeysMap = new Map(); } - addBlock(blockPosition: BlockPosition) { - const regionX = Math.floor(blockPosition.x / this.regionSize); - const regionY = Math.floor(blockPosition.y / this.regionSize); + private getKeys(x: number, y: number, width: number, height: number): string[] { + const keys: string[] = []; - let region = this.regions[regionY]?.[regionX]; - if (!region) { - region = { - regionX, - regionY, - blocks: [] - }; - if (!this.regions[regionY]) { - this.regions[regionY] = []; - } - this.regions[regionY][regionX] = region; - } - this.blocks.set(blockPosition.id, blockPosition); - region.blocks.push(blockPosition); - } - - updateBlock(blockId: string, position: BlockPosition) { - const prevPosition = this.blocks.get(blockId); - if (prevPosition && prevPosition.x === position.x && - prevPosition.y === position.y && - prevPosition.height === position.height && - prevPosition.width === position.width) { - return; - } - this.blocks.set(blockId, position); - this.removeBlock(blockId); - this.addBlock(position); - } - - removeBlock(blockId: string) { - for (const rows of this.regions.filter(r => r)) { - for (const region of rows) { - if (!region) return; - const blockIndex = region.blocks.findIndex(b => b.id === blockId); - if (blockIndex !== -1) { - region.blocks.splice(blockIndex, 1); - return; - } + for (let i = Math.floor(x / this.gridSize); i <= Math.floor((x + width) / this.gridSize); i++) { + for (let j = Math.floor(y / this.gridSize); j <= Math.floor((y + height) / this.gridSize); j++) { + keys.push(`${i},${j}`); } } - this.blocks.delete(blockId); + + return keys; } - - getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] { - const selectedBlocks: BlockPosition[] = []; + addBlock(block: BlockPosition): void { + const keys = this.getKeys(block.x, block.y, block.width, block.height); - const startRegionX = Math.floor(startX / this.regionSize); - const startRegionY = Math.floor(startY / this.regionSize); - const endRegionX = Math.floor(endX / this.regionSize); - const endRegionY = Math.floor(endY / this.regionSize); + this.blockKeysMap.set(block.id, keys); - for (let y = startRegionY; y <= endRegionY; y++) { - for (let x = startRegionX; x <= endRegionX; x++) { - const region = this.regions[y]?.[x]; - if (region) { - for (const block of region.blocks) { - if (block.x + block.width - 1 >= startX && block.x <= endX && - block.y + block.height - 1 >= startY && block.y <= endY) { - selectedBlocks.push(block); - } + for (const key of keys) { + if (!this.grid.has(key)) { + this.grid.set(key, []); + } + + this.grid.get(key)!.push(block); + } + } + + hasBlock(id: string) { + return this.blockKeysMap.has(id); + } + + updateBlock(block: BlockPosition): void { + if (this.hasBlock(block.id)) { + this.removeBlock(block); + } + this.addBlock(block); + } + + removeBlock(block: BlockPosition): void { + const keys = this.blockKeysMap.get(block.id) || []; + + for (const key of keys) { + const blocks = this.grid.get(key); + + if (blocks) { + const index = blocks.findIndex((b) => b.id === block.id); + + if (index !== -1) { + blocks.splice(index, 1); + + if (blocks.length === 0) { + this.grid.delete(key); } } } } + } - return selectedBlocks; + getIntersectingBlocks(rect: Rectangle): BlockPosition[] { + const blocks = new Set(); + const keys = this.getKeys(rect.x, rect.y, rect.width, rect.height); + + for (const key of keys) { + if (this.grid.has(key)) { + this.grid.get(key)?.forEach((block) => { + if ( + rect.x < block.x + block.width && + rect.x + rect.width > block.x && + rect.y < block.y + block.height && + rect.y + rect.height > block.y + ) + blocks.add(block); + }); + } + } + + return Array.from(blocks); } } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts index 92b8eb7ff3..74570454e2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts @@ -82,3 +82,19 @@ export function isEqual(value1: T, value2: T): boolean { } return true; } + +export function clone(value: T): T { + if (typeof value !== 'object' || value === null) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => clone(item)) as any; + } + + const result: any = {}; + for (const key in value) { + result[key] = clone(value[key]); + } + return result; +} \ No newline at end of file