mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: rectangular selection (#2480)
This commit is contained in:
parent
a2b592a59b
commit
37345578c1
@ -51,6 +51,12 @@ module.exports = {
|
|||||||
'no-void': 'off',
|
'no-void': 'off',
|
||||||
'prefer-const': 'warn',
|
'prefer-const': 'warn',
|
||||||
'prefer-spread': 'off',
|
'prefer-spread': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
ignorePatterns: ['src/**/*.test.ts'],
|
ignorePatterns: ['src/**/*.test.ts'],
|
||||||
};
|
};
|
||||||
|
@ -22,7 +22,6 @@
|
|||||||
"@mui/icons-material": "^5.11.11",
|
"@mui/icons-material": "^5.11.11",
|
||||||
"@mui/material": "^5.11.12",
|
"@mui/material": "^5.11.12",
|
||||||
"@reduxjs/toolkit": "^1.9.2",
|
"@reduxjs/toolkit": "^1.9.2",
|
||||||
"@slate-yjs/core": "^0.3.1",
|
|
||||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||||
"@tauri-apps/api": "^1.2.0",
|
"@tauri-apps/api": "^1.2.0",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
|
@ -22,9 +22,6 @@ dependencies:
|
|||||||
'@reduxjs/toolkit':
|
'@reduxjs/toolkit':
|
||||||
specifier: ^1.9.2
|
specifier: ^1.9.2
|
||||||
version: 1.9.3(react-redux@8.0.5)(react@18.2.0)
|
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':
|
'@tanstack/react-virtual':
|
||||||
specifier: 3.0.0-beta.54
|
specifier: 3.0.0-beta.54
|
||||||
version: 3.0.0-beta.54(react@18.2.0)
|
version: 3.0.0-beta.54(react@18.2.0)
|
||||||
@ -1412,17 +1409,6 @@ packages:
|
|||||||
'@sinonjs/commons': 2.0.0
|
'@sinonjs/commons': 2.0.0
|
||||||
dev: false
|
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):
|
/@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0):
|
||||||
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
|
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5133,12 +5119,6 @@ packages:
|
|||||||
yjs: 13.5.51
|
yjs: 13.5.51
|
||||||
dev: false
|
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:
|
/y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -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 { useAppDispatch } from '@/appflowy_app/stores/store';
|
||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ export function useBlockMenu(nodeId: string, open: boolean) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// set selection when open
|
// set selection when open
|
||||||
dispatch(documentActions.setSelectionById(nodeId));
|
dispatch(rectSelectionActions.setSelectionById(nodeId));
|
||||||
// get node rect
|
// get node rect
|
||||||
const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect();
|
const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
@ -21,7 +21,7 @@ export function useBlockMenu(nodeId: string, open: boolean) {
|
|||||||
top: rect.top + 'px',
|
top: rect.top + 'px',
|
||||||
left: rect.left + 'px',
|
left: rect.left + 'px',
|
||||||
});
|
});
|
||||||
}, [open, nodeId]);
|
}, [open, nodeId, dispatch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ref,
|
ref,
|
||||||
|
@ -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 { 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({
|
export function useBlockSelection({
|
||||||
container,
|
container,
|
||||||
@ -13,12 +15,13 @@ export function useBlockSelection({
|
|||||||
const disaptch = useAppDispatch();
|
const disaptch = useAppDispatch();
|
||||||
|
|
||||||
const [isDragging, setDragging] = useState(false);
|
const [isDragging, setDragging] = useState(false);
|
||||||
const pointRef = useRef<number[]>([]);
|
const startPointRef = useRef<number[]>([]);
|
||||||
const startScrollTopRef = useRef<number>(0);
|
|
||||||
|
const { getIntersectedBlockIds } = useNodesRect(container);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDragging?.(isDragging);
|
onDragging?.(isDragging);
|
||||||
}, [isDragging]);
|
}, [isDragging, onDragging]);
|
||||||
|
|
||||||
const [rect, setRect] = useState<{
|
const [rect, setRect] = useState<{
|
||||||
startX: number;
|
startX: number;
|
||||||
@ -40,7 +43,7 @@ export function useBlockSelection({
|
|||||||
width: width + 'px',
|
width: width + 'px',
|
||||||
height: height + 'px',
|
height: height + 'px',
|
||||||
};
|
};
|
||||||
}, [rect]);
|
}, [container.scrollLeft, container.scrollTop, rect]);
|
||||||
|
|
||||||
const isPointInBlock = useCallback((target: HTMLElement | null) => {
|
const isPointInBlock = useCallback((target: HTMLElement | null) => {
|
||||||
let node = target;
|
let node = target;
|
||||||
@ -53,48 +56,45 @@ export function useBlockSelection({
|
|||||||
return false;
|
return false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: MouseEvent) => {
|
const handleDragStart = useCallback(
|
||||||
if (isPointInBlock(e.target as HTMLElement)) {
|
(e: MouseEvent) => {
|
||||||
return;
|
if (isPointInBlock(e.target as HTMLElement)) {
|
||||||
}
|
return;
|
||||||
e.preventDefault();
|
}
|
||||||
setDragging(true);
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
|
||||||
const startX = e.clientX + container.scrollLeft;
|
const startX = e.clientX + container.scrollLeft;
|
||||||
const startY = e.clientY + container.scrollTop;
|
const startY = e.clientY + container.scrollTop;
|
||||||
pointRef.current = [startX, startY];
|
startPointRef.current = [startX, startY];
|
||||||
startScrollTopRef.current = container.scrollTop;
|
setRect({
|
||||||
setRect({
|
startX,
|
||||||
startX,
|
startY,
|
||||||
startY,
|
endX: startX,
|
||||||
endX: startX,
|
endY: startY,
|
||||||
endY: startY,
|
});
|
||||||
});
|
},
|
||||||
}, []);
|
[container.scrollLeft, container.scrollTop, isPointInBlock]
|
||||||
|
);
|
||||||
|
|
||||||
const updateSelctionsByPoint = useCallback(
|
const updateSelctionsByPoint = useCallback(
|
||||||
(clientX: number, clientY: number) => {
|
(clientX: number, clientY: number) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
const [startX, startY] = pointRef.current;
|
const [startX, startY] = startPointRef.current;
|
||||||
const endX = clientX + container.scrollLeft;
|
const endX = clientX + container.scrollLeft;
|
||||||
const endY = clientY + container.scrollTop;
|
const endY = clientY + container.scrollTop;
|
||||||
|
|
||||||
setRect({
|
const newRect = {
|
||||||
startX,
|
startX,
|
||||||
startY,
|
startY,
|
||||||
endX,
|
endX,
|
||||||
endY,
|
endY,
|
||||||
});
|
};
|
||||||
disaptch(
|
const blockIds = getIntersectedBlockIds(newRect);
|
||||||
documentActions.setSelectionByRect({
|
setRect(newRect);
|
||||||
startX: Math.min(startX, endX),
|
disaptch(setRectSelectionThunk(blockIds));
|
||||||
startY: Math.min(startY, endY),
|
|
||||||
endX: Math.max(startX, endX),
|
|
||||||
endY: Math.max(startY, endY),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[isDragging]
|
[container.scrollLeft, container.scrollTop, disaptch, getIntersectedBlockIds, isDragging]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraging = useCallback(
|
const handleDraging = useCallback(
|
||||||
@ -113,13 +113,13 @@ export function useBlockSelection({
|
|||||||
container.scrollBy(0, delta);
|
container.scrollBy(0, delta);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDragging]
|
[container, isDragging, updateSelctionsByPoint]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
||||||
disaptch(documentActions.updateSelections([]));
|
disaptch(rectSelectionActions.updateSelections([]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@ -128,21 +128,21 @@ export function useBlockSelection({
|
|||||||
setDragging(false);
|
setDragging(false);
|
||||||
setRect(null);
|
setRect(null);
|
||||||
},
|
},
|
||||||
[isDragging]
|
[disaptch, isDragging, isPointInBlock, updateSelctionsByPoint]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
container.addEventListener('mousedown', handleDragStart);
|
document.addEventListener('mousedown', handleDragStart);
|
||||||
container.addEventListener('mousemove', handleDraging);
|
document.addEventListener('mousemove', handleDraging);
|
||||||
container.addEventListener('mouseup', handleDragEnd);
|
document.addEventListener('mouseup', handleDragEnd);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('mousedown', handleDragStart);
|
document.removeEventListener('mousedown', handleDragStart);
|
||||||
container.removeEventListener('mousemove', handleDraging);
|
document.removeEventListener('mousemove', handleDraging);
|
||||||
container.removeEventListener('mouseup', handleDragEnd);
|
document.removeEventListener('mouseup', handleDragEnd);
|
||||||
};
|
};
|
||||||
}, [handleDragStart, handleDragEnd, handleDraging, container]);
|
}, [handleDragStart, handleDragEnd, handleDraging]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDragging,
|
isDragging,
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -24,6 +24,7 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// prevent toolbar from taking focus away from editor
|
// prevent toolbar from taking focus away from editor
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton onClick={() => handleToggleMenu(true)} sx={sx}>
|
<IconButton onClick={() => handleToggleMenu(true)} sx={sx}>
|
||||||
|
@ -14,7 +14,7 @@ export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
|
|||||||
const id = node.id;
|
const id = node.id;
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const controller = useContext(DocumentControllerContext);
|
||||||
const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
|
const { editor, ...rest } = useTextInput(id);
|
||||||
const defaultTextInputEvents = useDefaultTextInputEvents(id);
|
const defaultTextInputEvents = useDefaultTextInputEvents(id);
|
||||||
|
|
||||||
const customEvents = useMemo(() => {
|
const customEvents = useMemo(() => {
|
||||||
@ -81,8 +81,6 @@ export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
|
|||||||
return {
|
return {
|
||||||
editor,
|
editor,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onChange,
|
...rest
|
||||||
value,
|
|
||||||
onDOMBeforeInput,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ export default function CodeBlock({
|
|||||||
placeholder,
|
placeholder,
|
||||||
...props
|
...props
|
||||||
}: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
}: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useCodeBlock(node);
|
const { editor, value, onChange, ...rest } = useCodeBlock(node);
|
||||||
|
|
||||||
const className = props.className ? ` ${props.className}` : '';
|
const className = props.className ? ` ${props.className}` : '';
|
||||||
const id = node.id;
|
const id = node.id;
|
||||||
@ -24,11 +24,9 @@ export default function CodeBlock({
|
|||||||
</div>
|
</div>
|
||||||
<Slate editor={editor} onChange={onChange} value={value}>
|
<Slate editor={editor} onChange={onChange} value={value}>
|
||||||
<BlockHorizontalToolbar id={id} />
|
<BlockHorizontalToolbar id={id} />
|
||||||
|
|
||||||
<Editable
|
<Editable
|
||||||
onKeyDown={onKeyDown}
|
{...rest}
|
||||||
decorate={(entry) => decorateCodeFunc(entry, language)}
|
decorate={(entry) => decorateCodeFunc(entry, language)}
|
||||||
onDOMBeforeInput={onDOMBeforeInput}
|
|
||||||
renderLeaf={CodeLeaf}
|
renderLeaf={CodeLeaf}
|
||||||
renderElement={CodeBlockElement}
|
renderElement={CodeBlockElement}
|
||||||
placeholder={placeholder || 'Please enter some text...'}
|
placeholder={placeholder || 'Please enter some text...'}
|
||||||
|
@ -1,32 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
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) {
|
export function useNode(id: string) {
|
||||||
const { node, childIds, isSelected } = useSubscribeNode(id);
|
const { node, childIds, isSelected } = useSubscribeNode(id);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(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 {
|
return {
|
||||||
ref,
|
ref,
|
||||||
node,
|
node,
|
||||||
|
@ -2,14 +2,13 @@ import { useTextInput } from '../_shared/Text/TextInput.hooks';
|
|||||||
import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
|
import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
|
||||||
|
|
||||||
export function useTextBlock(id: string) {
|
export function useTextBlock(id: string) {
|
||||||
const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
|
const { editor, ...rest } =
|
||||||
|
useTextInput(id);
|
||||||
const { onKeyDown } = useTextBlockKeyEvent(id, editor);
|
const { onKeyDown } = useTextBlockKeyEvent(id, editor);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onChange,
|
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onDOMBeforeInput,
|
|
||||||
editor,
|
editor,
|
||||||
value,
|
...rest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,12 @@ function TextBlock({
|
|||||||
childIds?: string[];
|
childIds?: string[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useTextBlock(node.id);
|
const {
|
||||||
|
editor,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...rest
|
||||||
|
} = useTextBlock(node.id);
|
||||||
const className = props.className !== undefined ? ` ${props.className}` : '';
|
const className = props.className !== undefined ? ` ${props.className}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -25,8 +30,7 @@ function TextBlock({
|
|||||||
<Slate editor={editor} onChange={onChange} value={value}>
|
<Slate editor={editor} onChange={onChange} value={value}>
|
||||||
<BlockHorizontalToolbar id={node.id} />
|
<BlockHorizontalToolbar id={node.id} />
|
||||||
<Editable
|
<Editable
|
||||||
onKeyDown={onKeyDown}
|
{...rest}
|
||||||
onDOMBeforeInput={onDOMBeforeInput}
|
|
||||||
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
|
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
|
||||||
placeholder={placeholder || 'Please enter some text...'}
|
placeholder={placeholder || 'Please enter some text...'}
|
||||||
/>
|
/>
|
||||||
|
@ -31,6 +31,7 @@ export default function VirtualizedList({
|
|||||||
>
|
>
|
||||||
{node && childIds && virtualItems.length ? (
|
{node && childIds && virtualItems.length ? (
|
||||||
<div
|
<div
|
||||||
|
className={'doc-body-inner'}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -19,7 +19,7 @@ export function useSubscribeNode(id: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSelected = useAppSelector<boolean>((state) => {
|
const isSelected = useAppSelector<boolean>((state) => {
|
||||||
return state.document.selections?.includes(id) || false;
|
return state.rectSelection.selections?.includes(id) || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the node and its children
|
// Memoize the node and its children
|
||||||
@ -27,7 +27,7 @@ export function useSubscribeNode(id: string) {
|
|||||||
// It very important for performance
|
// It very important for performance
|
||||||
const memoizedNode = useMemo(
|
const memoizedNode = useMemo(
|
||||||
() => node,
|
() => node,
|
||||||
[node?.id, JSON.stringify(node?.data), node?.parent, node?.type, node?.children]
|
[JSON.stringify(node)]
|
||||||
);
|
);
|
||||||
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
|
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
|
||||||
|
|
||||||
|
@ -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 { 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 { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
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 { NodeContext } from '../SubscribeNode.hooks';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
|
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
|
||||||
import { deltaToSlateValue } from '$app/utils/document/blocks/common';
|
import { deltaToSlateValue, getCollapsedRange, slateValueToDelta } from "$app/utils/document/blocks/common";
|
||||||
import { documentActions } from '$app_reducers/document/slice';
|
import { rangeSelectionActions } from "$app_reducers/document/slice";
|
||||||
|
import { getNodeEndSelection, isSameDelta } from '$app/utils/document/blocks/text/delta';
|
||||||
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
|
|
||||||
|
|
||||||
export function useTextInput(id: string) {
|
export function useTextInput(id: string) {
|
||||||
const dispatch = useAppDispatch();
|
const [editor] = useState(() => withReact(createEditor()));
|
||||||
const node = useContext(NodeContext);
|
const node = useContext(NodeContext);
|
||||||
const selectionRef = useRef<TextSelection | null>(null);
|
const { sendDelta } = useController(id);
|
||||||
|
const { storeSelection } = useSelection(id, editor);
|
||||||
|
const isComposition = useRef(false);
|
||||||
|
|
||||||
const delta = useMemo(() => {
|
const delta = useMemo(() => {
|
||||||
if (!node || !('delta' in node.data)) {
|
if (!node || !('delta' in node.data)) {
|
||||||
@ -25,62 +24,40 @@ export function useTextInput(id: string) {
|
|||||||
}
|
}
|
||||||
return node.data.delta;
|
return node.data.delta;
|
||||||
}, [node]);
|
}, [node]);
|
||||||
|
const [value, setValue] = useState<Descendant[]>(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<Descendant[]>([]);
|
// 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(() => {
|
const slateValue = deltaToSlateValue(delta);
|
||||||
if (!ReactEditor.isFocused(editor)) {
|
editor.children = slateValue;
|
||||||
selectionRef.current = null;
|
setValue(slateValue);
|
||||||
return;
|
}, [delta, editor]);
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
|
// Update the node's delta when the editor's value changes.
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(e: Descendant[]) => {
|
(e: Descendant[]) => {
|
||||||
|
// Update the editor's value and selection.
|
||||||
setValue(e);
|
setValue(e);
|
||||||
storeSelection();
|
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) => {
|
const onDOMBeforeInput = useCallback((e: InputEvent) => {
|
||||||
// COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
|
// COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
|
||||||
// It will cause repeated characters when inputting Chinese.
|
// 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 {
|
return {
|
||||||
editor,
|
editor,
|
||||||
yText,
|
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
onDOMBeforeInput,
|
onDOMBeforeInput,
|
||||||
|
onCompositionStart,
|
||||||
|
onCompositionUpdate,
|
||||||
|
onCompositionEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function useBindYjs(id: string, delta: TextDelta[]) {
|
|
||||||
const { sendDelta } = useController(id);
|
|
||||||
const yTextRef = useRef<Y.XmlText>();
|
|
||||||
|
|
||||||
// Create a yjs document and get the shared type
|
|
||||||
const sharedType = useMemo(() => {
|
|
||||||
const doc = new Y.Doc();
|
|
||||||
const _sharedType = doc.get('content', Y.XmlText) as Y.XmlText;
|
|
||||||
|
|
||||||
const insertDelta = slateNodesToInsertDelta(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) {
|
function useController(id: string) {
|
||||||
const docController = useContext(DocumentControllerContext);
|
const docController = useContext(DocumentControllerContext);
|
||||||
@ -180,3 +112,96 @@ function useController(id: string) {
|
|||||||
sendDelta,
|
sendDelta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
|
import { RegionGrid } from '$app/utils/region_grid';
|
||||||
|
|
||||||
export enum BlockType {
|
export enum BlockType {
|
||||||
PageBlock = 'page',
|
PageBlock = 'page',
|
||||||
@ -127,10 +128,17 @@ export interface DocumentState {
|
|||||||
nodes: Record<string, Node>;
|
nodes: Record<string, Node>;
|
||||||
// map of block id to children block ids
|
// map of block id to children block ids
|
||||||
children: Record<string, string[]>;
|
children: Record<string, string[]>;
|
||||||
// selected block ids
|
}
|
||||||
selections: string[];
|
|
||||||
// map of block id to text selection
|
export interface RangeSelectionState {
|
||||||
textSelections: Record<string, TextSelection>;
|
anchor?: PointState,
|
||||||
|
focus?: PointState,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PointState {
|
||||||
|
id: string,
|
||||||
|
selection: TextSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ChangeType {
|
export enum ChangeType {
|
||||||
|
@ -24,11 +24,11 @@ export class DocumentController {
|
|||||||
private readonly observer: DocumentObserver;
|
private readonly observer: DocumentObserver;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly viewId: string,
|
public readonly documentId: string,
|
||||||
private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void
|
private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void
|
||||||
) {
|
) {
|
||||||
this.backendService = new DocumentBackendService(viewId);
|
this.backendService = new DocumentBackendService(documentId);
|
||||||
this.observer = new DocumentObserver(viewId);
|
this.observer = new DocumentObserver(documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
create = async (): Promise<FlowyError | void> => {
|
create = async (): Promise<FlowyError | void> => {
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { DocumentState } from '$app/interfaces/document';
|
import { DocumentState } from '$app/interfaces/document';
|
||||||
import { getPrevLineId } from '$app/utils/document/blocks/common';
|
import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common";
|
||||||
import { setCursorAfterThunk } from '$app_reducers/document/async-actions';
|
import { documentActions, rangeSelectionActions } from "$app_reducers/document/slice";
|
||||||
import { documentActions } from '$app_reducers/document/slice';
|
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
|
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
|
||||||
|
|
||||||
@ -80,6 +79,7 @@ export const mergeToPrevLineThunk = createAsyncThunk(
|
|||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
|
||||||
// set cursor after the prev line
|
// set cursor after the prev line
|
||||||
dispatch(documentActions.setTextSelection({ blockId: prevLine.id, selection }));
|
const range = getCollapsedRange(prevLine.id, selection);
|
||||||
|
dispatch(rangeSelectionActions.setRange(range));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { documentActions } from '$app_reducers/document/slice';
|
import { documentActions } from '$app_reducers/document/slice';
|
||||||
import { debounce } from '$app/utils/tool';
|
import { debounce } from '$app/utils/tool';
|
||||||
|
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
|
||||||
export const updateNodeDeltaThunk = createAsyncThunk(
|
export const updateNodeDeltaThunk = createAsyncThunk(
|
||||||
'document/updateNodeDelta',
|
'document/updateNodeDelta',
|
||||||
async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => {
|
||||||
@ -10,6 +11,8 @@ export const updateNodeDeltaThunk = createAsyncThunk(
|
|||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = (getState() as { document: DocumentState }).document;
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
|
const isSame = isSameDelta(delta, node.data.delta);
|
||||||
|
if (isSame) return;
|
||||||
// The block map should be updated immediately
|
// The block map should be updated immediately
|
||||||
// or the component will use the old data to update the editor
|
// or the component will use the old data to update the editor
|
||||||
dispatch(documentActions.updateNodeData({ id, data: { delta } }));
|
dispatch(documentActions.updateNodeData({ id, data: { delta } }));
|
||||||
@ -34,7 +37,7 @@ const debounceApplyUpdate = debounce((controller: DocumentController, updateNode
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}, 200);
|
}, 500);
|
||||||
|
|
||||||
export const updateNodeDataThunk = createAsyncThunk<
|
export const updateNodeDataThunk = createAsyncThunk<
|
||||||
void,
|
void,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { documentActions } from '../slice';
|
import { rangeSelectionActions } from "../slice";
|
||||||
import { DocumentState, TextSelection } from '$app/interfaces/document';
|
import { DocumentState, TextSelection } from '$app/interfaces/document';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import {
|
import {
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
getNodeEndSelection,
|
getNodeEndSelection,
|
||||||
getStartLineSelectionByOffset,
|
getStartLineSelectionByOffset,
|
||||||
} from '$app/utils/document/blocks/text/delta';
|
} 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(
|
export const setCursorBeforeThunk = createAsyncThunk(
|
||||||
'document/setCursorBefore',
|
'document/setCursorBefore',
|
||||||
@ -18,7 +18,9 @@ export const setCursorBeforeThunk = createAsyncThunk(
|
|||||||
const { id } = payload;
|
const { id } = payload;
|
||||||
const { dispatch } = thunkAPI;
|
const { dispatch } = thunkAPI;
|
||||||
const selection = getNodeBeginSelection();
|
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 state = (getState() as { document: DocumentState }).document;
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
const selection = getNodeEndSelection(node.data.delta);
|
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
|
// set the cursor to prev line with the relative offset
|
||||||
const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset);
|
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
|
// set the cursor to next line with the relative offset
|
||||||
const newSelection = getStartLineSelectionByOffset(delta, textOffset);
|
const newSelection = getStartLineSelectionByOffset(delta, textOffset);
|
||||||
|
|
||||||
dispatch(documentActions.setTextSelection({ blockId: nextLineNode.id, selection: newSelection }));
|
dispatch(rangeSelectionActions.setRange(getCollapsedRange(nextLineNode.id, newSelection)));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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<string, boolean> = {};
|
||||||
|
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])))
|
||||||
|
}
|
||||||
|
);
|
@ -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 { BlockEventPayloadPB } from '@/services/backend';
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { combineReducers, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
import { RegionGrid } from '@/appflowy_app/utils/region_grid';
|
|
||||||
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
||||||
|
import blockSelection from "$app/components/document/BlockSelection";
|
||||||
const regionGrid = new RegionGrid(50);
|
import { databaseSlice } from "$app_reducers/database/slice";
|
||||||
|
|
||||||
const initialState: DocumentState = {
|
const initialState: DocumentState = {
|
||||||
nodes: {},
|
nodes: {},
|
||||||
children: {},
|
children: {},
|
||||||
selections: [],
|
|
||||||
textSelections: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rectSelectionInitialState: {
|
||||||
|
selections: string[];
|
||||||
|
} = {
|
||||||
|
selections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeSelectionInitialState: RangeSelectionState = {};
|
||||||
|
|
||||||
export const documentSlice = createSlice({
|
export const documentSlice = createSlice({
|
||||||
name: 'document',
|
name: 'document',
|
||||||
initialState: initialState,
|
initialState: initialState,
|
||||||
@ -35,81 +40,6 @@ export const documentSlice = createSlice({
|
|||||||
state.children = children;
|
state.children = children;
|
||||||
},
|
},
|
||||||
|
|
||||||
// update block selections
|
|
||||||
updateSelections: (state, action: PayloadAction<string[]>) => {
|
|
||||||
state.selections = action.payload;
|
|
||||||
},
|
|
||||||
|
|
||||||
// set block selected
|
|
||||||
setSelectionById: (state, action: PayloadAction<string>) => {
|
|
||||||
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<string>) => {
|
|
||||||
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,
|
// 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.
|
// 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> }>) => {
|
updateNodeData: (state, action: PayloadAction<{ id: string; data: Record<string, any> }>) => {
|
||||||
@ -145,4 +75,49 @@ export const documentSlice = createSlice({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const rectSelectionSlice = createSlice({
|
||||||
|
name: 'rectSelection',
|
||||||
|
initialState: rectSelectionInitialState,
|
||||||
|
reducers: {
|
||||||
|
// update block selections
|
||||||
|
updateSelections: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.selections = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
// set block selected
|
||||||
|
setSelectionById: (state, action: PayloadAction<string>) => {
|
||||||
|
const id = action.payload;
|
||||||
|
state.selections = [id];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const rangeSelectionSlice = createSlice({
|
||||||
|
name: 'rangeSelection',
|
||||||
|
initialState: rangeSelectionInitialState,
|
||||||
|
reducers: {
|
||||||
|
setRange: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<RangeSelectionState>
|
||||||
|
) => {
|
||||||
|
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 documentActions = documentSlice.actions;
|
||||||
|
export const rectSelectionActions = rectSelectionSlice.actions;
|
||||||
|
export const rangeSelectionActions = rangeSelectionSlice.actions;
|
@ -14,7 +14,7 @@ import { currentUserSlice } from './reducers/current-user/slice';
|
|||||||
import { gridSlice } from './reducers/grid/slice';
|
import { gridSlice } from './reducers/grid/slice';
|
||||||
import { workspaceSlice } from './reducers/workspace/slice';
|
import { workspaceSlice } from './reducers/workspace/slice';
|
||||||
import { databaseSlice } from './reducers/database/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 { boardSlice } from './reducers/board/slice';
|
||||||
import { errorSlice } from './reducers/error/slice';
|
import { errorSlice } from './reducers/error/slice';
|
||||||
import { activePageIdSlice } from '$app_reducers/active-page-id/slice';
|
import { activePageIdSlice } from '$app_reducers/active-page-id/slice';
|
||||||
@ -33,9 +33,9 @@ const store = configureStore({
|
|||||||
[gridSlice.name]: gridSlice.reducer,
|
[gridSlice.name]: gridSlice.reducer,
|
||||||
[databaseSlice.name]: databaseSlice.reducer,
|
[databaseSlice.name]: databaseSlice.reducer,
|
||||||
[boardSlice.name]: boardSlice.reducer,
|
[boardSlice.name]: boardSlice.reducer,
|
||||||
[documentSlice.name]: documentSlice.reducer,
|
|
||||||
[workspaceSlice.name]: workspaceSlice.reducer,
|
[workspaceSlice.name]: workspaceSlice.reducer,
|
||||||
[errorSlice.name]: errorSlice.reducer,
|
[errorSlice.name]: errorSlice.reducer,
|
||||||
|
...documentReducers,
|
||||||
},
|
},
|
||||||
middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),
|
middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),
|
||||||
});
|
});
|
||||||
|
@ -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 { Descendant, Element, Text } from 'slate';
|
||||||
import { BlockPB } from '@/services/backend';
|
import { BlockPB } from '@/services/backend';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
import { nanoid } from 'nanoid';
|
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[]) {
|
export function deltaToSlateValue(delta: TextDelta[]) {
|
||||||
const slateNode = {
|
const slateNode = {
|
||||||
@ -101,6 +122,16 @@ export function getNextNodeId(state: DocumentState, id: string) {
|
|||||||
return nextNodeId;
|
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>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
|
export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@ -110,3 +141,14 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState {
|
||||||
|
const point = {
|
||||||
|
id,
|
||||||
|
selection
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
anchor: clone(point),
|
||||||
|
focus: clone(point),
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
|
import { DeltaTypePB } from "@/services/backend/models/flowy-document2";
|
||||||
import { BlockType, NestedBlock, DocumentState, ChangeType, BlockPBValue } from '../../interfaces/document';
|
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from "$app/interfaces/document";
|
||||||
import { Log } from '../log';
|
import { Log } from "../log";
|
||||||
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '../../constants/document/block';
|
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
|
// This is a list of all the possible changes that can happen to document data
|
||||||
const matchCases = [
|
const matchCases = [
|
||||||
@ -100,8 +100,7 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
|
function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
|
||||||
const block = blockChangeValue2Node(blockValue);
|
state.nodes[blockId] = blockChangeValue2Node(blockValue);
|
||||||
state.nodes[blockId] = block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) {
|
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) {
|
||||||
@ -125,12 +124,7 @@ function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: B
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMatchBlockDelete(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
|
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];
|
|
||||||
delete state.nodes[blockId];
|
delete state.nodes[blockId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,92 +5,100 @@ export interface BlockPosition {
|
|||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
interface BlockRegion {
|
|
||||||
regionX: number;
|
interface Rectangle {
|
||||||
regionY: number;
|
x: number;
|
||||||
blocks: BlockPosition[];
|
y: number;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegionGrid {
|
export class RegionGrid {
|
||||||
private regions: BlockRegion[][];
|
private readonly gridSize: number;
|
||||||
private regionSize: number;
|
private readonly grid: Map<string, BlockPosition[]>;
|
||||||
private blocks = new Map();
|
private readonly blockKeysMap: Map<string, string[]>;
|
||||||
|
|
||||||
constructor(regionSize: number) {
|
constructor(gridSize: number) {
|
||||||
this.regionSize = regionSize;
|
this.gridSize = gridSize;
|
||||||
this.regions = [];
|
this.grid = new Map();
|
||||||
|
this.blockKeysMap = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
addBlock(blockPosition: BlockPosition) {
|
private getKeys(x: number, y: number, width: number, height: number): string[] {
|
||||||
const regionX = Math.floor(blockPosition.x / this.regionSize);
|
const keys: string[] = [];
|
||||||
const regionY = Math.floor(blockPosition.y / this.regionSize);
|
|
||||||
|
|
||||||
let region = this.regions[regionY]?.[regionX];
|
for (let i = Math.floor(x / this.gridSize); i <= Math.floor((x + width) / this.gridSize); i++) {
|
||||||
if (!region) {
|
for (let j = Math.floor(y / this.gridSize); j <= Math.floor((y + height) / this.gridSize); j++) {
|
||||||
region = {
|
keys.push(`${i},${j}`);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.blocks.delete(blockId);
|
|
||||||
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] {
|
addBlock(block: BlockPosition): void {
|
||||||
const selectedBlocks: BlockPosition[] = [];
|
const keys = this.getKeys(block.x, block.y, block.width, block.height);
|
||||||
|
|
||||||
const startRegionX = Math.floor(startX / this.regionSize);
|
this.blockKeysMap.set(block.id, keys);
|
||||||
const startRegionY = Math.floor(startY / this.regionSize);
|
|
||||||
const endRegionX = Math.floor(endX / this.regionSize);
|
|
||||||
const endRegionY = Math.floor(endY / this.regionSize);
|
|
||||||
|
|
||||||
for (let y = startRegionY; y <= endRegionY; y++) {
|
for (const key of keys) {
|
||||||
for (let x = startRegionX; x <= endRegionX; x++) {
|
if (!this.grid.has(key)) {
|
||||||
const region = this.regions[y]?.[x];
|
this.grid.set(key, []);
|
||||||
if (region) {
|
}
|
||||||
for (const block of region.blocks) {
|
|
||||||
if (block.x + block.width - 1 >= startX && block.x <= endX &&
|
this.grid.get(key)!.push(block);
|
||||||
block.y + block.height - 1 >= startY && block.y <= endY) {
|
}
|
||||||
selectedBlocks.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<BlockPosition>();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,3 +82,19 @@ export function isEqual<T>(value1: T, value2: T): boolean {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clone<T>(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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user