mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Support document multiple instance and change doc_id from String to &str (#2808)
* fix: support multiple document * fix: change the doc_id params to ref * fix: function to converge subscription state * fix: mousedown behavior update * fix: turn to textblock when the enter pressed in empty block * fix: support cut * fix: emoji caret * fix: support slash arrow key * fix: eslint padding-line-between-statements * fix: add comment * fix: block side menu bugs * fix: support key event to select block menu option * fix: support side menu arrow key * fix: optimizate selectOptionByUpDown * fix: format
This commit is contained in:
parent
d986e01d3e
commit
177f7c4fa3
@ -57,6 +57,16 @@ module.exports = {
|
|||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
'padding-line-between-statements': [
|
||||||
|
"warn",
|
||||||
|
{ blankLine: "always", prev: ["const", "let", "var"], next: "*"},
|
||||||
|
{ blankLine: "any", prev: ["const", "let", "var"], next: ["const", "let", "var"]},
|
||||||
|
{ blankLine: "always", prev: "import", next: "*" },
|
||||||
|
{ blankLine: "any", prev: "import", next: "import" },
|
||||||
|
{ blankLine: "always", prev: "block-like", next: "*" },
|
||||||
|
{ blankLine: "always", prev: "block", next: "*" },
|
||||||
|
|
||||||
|
]
|
||||||
},
|
},
|
||||||
ignorePatterns: ['src/**/*.test.ts'],
|
ignorePatterns: ['src/**/*.test.ts'],
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { rangeActions } from '$app_reducers/document/slice';
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import {
|
import {
|
||||||
getBlockIdByPoint,
|
getBlockIdByPoint,
|
||||||
getNodeTextBoxByBlockId,
|
getNodeTextBoxByBlockId,
|
||||||
@ -9,12 +9,16 @@ import {
|
|||||||
setCursorAtStartOfNode,
|
setCursorAtStartOfNode,
|
||||||
} from '$app/utils/document/node';
|
} from '$app/utils/document/node';
|
||||||
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
|
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
|
||||||
export function useBlockRangeSelection(container: HTMLDivElement) {
|
export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const onKeyDown = useRangeKeyDown();
|
const onKeyDown = useRangeKeyDown();
|
||||||
const range = useAppSelector((state) => state.documentRange);
|
const { docId } = useSubscribeDocument();
|
||||||
const isDragging = range.isDragging;
|
|
||||||
|
const range = useSubscribeRanges();
|
||||||
|
const isDragging = range?.isDragging;
|
||||||
|
|
||||||
const anchorRef = useRef<{
|
const anchorRef = useRef<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -28,13 +32,9 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
|
|
||||||
const [isForward, setForward] = useState(true);
|
const [isForward, setForward] = useState(true);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
dispatch(rangeActions.clearRange());
|
|
||||||
setForward(true);
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
// display caret color
|
// display caret color
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!range) return;
|
||||||
const { anchor, focus } = range;
|
const { anchor, focus } = range;
|
||||||
if (!anchor || !focus) {
|
if (!anchor || !focus) {
|
||||||
container.classList.remove('caret-transparent');
|
container.classList.remove('caret-transparent');
|
||||||
@ -54,7 +54,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
// update focus point
|
// update focus point
|
||||||
dispatch(rangeActions.setFocusPoint(focus));
|
dispatch(
|
||||||
|
rangeActions.setFocusPoint({
|
||||||
|
...focus,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const focused = isFocused(focus.id);
|
const focused = isFocused(focus.id);
|
||||||
// if the focus block is not focused, we need to set the cursor position
|
// if the focus block is not focused, we need to set the cursor position
|
||||||
@ -82,17 +87,18 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
setCursorAtEndOfNode(node);
|
setCursorAtEndOfNode(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [container, dispatch, focus, isForward]);
|
}, [container, dispatch, docId, focus, isForward]);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
reset();
|
setForward(true);
|
||||||
// skip if the target is not a block
|
// skip if the target is not a block
|
||||||
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
||||||
if (!blockId) {
|
if (!blockId) {
|
||||||
|
dispatch(rangeActions.initialState(docId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
|
||||||
const startX = e.clientX + container.scrollLeft;
|
const startX = e.clientX + container.scrollLeft;
|
||||||
const startY = e.clientY + container.scrollTop;
|
const startY = e.clientY + container.scrollTop;
|
||||||
|
|
||||||
@ -108,11 +114,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
...anchor,
|
...anchor,
|
||||||
};
|
};
|
||||||
// set the anchor point and focus point
|
// set the anchor point and focus point
|
||||||
dispatch(rangeActions.setAnchorPoint({ ...anchor }));
|
dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
|
||||||
dispatch(rangeActions.setFocusPoint({ ...anchor }));
|
dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
|
||||||
dispatch(rangeActions.setDragging(true));
|
dispatch(
|
||||||
|
rangeActions.setDragging({
|
||||||
|
isDragging: true,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
},
|
},
|
||||||
[container.scrollLeft, container.scrollTop, dispatch, reset]
|
[container.scrollLeft, container.scrollTop, dispatch, docId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraging = useCallback(
|
const handleDraging = useCallback(
|
||||||
@ -152,8 +164,13 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
setFocus(null);
|
setFocus(null);
|
||||||
anchorRef.current = null;
|
anchorRef.current = null;
|
||||||
dispatch(rangeActions.setDragging(false));
|
dispatch(
|
||||||
}, [dispatch, isDragging]);
|
rangeActions.setDragging({
|
||||||
|
isDragging: false,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [docId, dispatch, isDragging]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('mousedown', handleDragStart);
|
document.addEventListener('mousedown', handleDragStart);
|
||||||
@ -164,9 +181,10 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
document.removeEventListener('mousedown', handleDragStart);
|
document.removeEventListener('mousedown', handleDragStart);
|
||||||
document.removeEventListener('mousemove', handleDraging);
|
document.removeEventListener('mousemove', handleDraging);
|
||||||
document.removeEventListener('mouseup', handleDragEnd);
|
document.removeEventListener('mouseup', handleDragEnd);
|
||||||
|
|
||||||
container.removeEventListener('keydown', onKeyDown, true);
|
container.removeEventListener('keydown', onKeyDown, true);
|
||||||
};
|
};
|
||||||
}, [reset, handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
|
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/sl
|
|||||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||||
|
|
||||||
import { isPointInBlock } from '$app/utils/document/node';
|
import { isPointInBlock } from '$app/utils/document/node';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export interface BlockRectSelectionProps {
|
export interface BlockRectSelectionProps {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
@ -12,13 +13,19 @@ export interface BlockRectSelectionProps {
|
|||||||
|
|
||||||
export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) {
|
export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const [isDragging, setDragging] = useState(false);
|
const [isDragging, setDragging] = useState(false);
|
||||||
const startPointRef = useRef<number[]>([]);
|
const startPointRef = useRef<number[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(rectSelectionActions.setDragging(isDragging));
|
dispatch(
|
||||||
}, [dispatch, isDragging]);
|
rectSelectionActions.setDragging({
|
||||||
|
docId,
|
||||||
|
isDragging,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [docId, dispatch, isDragging]);
|
||||||
|
|
||||||
const [rect, setRect] = useState<{
|
const [rect, setRect] = useState<{
|
||||||
startX: number;
|
startX: number;
|
||||||
@ -78,9 +85,14 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
|
|||||||
};
|
};
|
||||||
const blockIds = getIntersectedBlockIds(newRect);
|
const blockIds = getIntersectedBlockIds(newRect);
|
||||||
setRect(newRect);
|
setRect(newRect);
|
||||||
dispatch(setRectSelectionThunk(blockIds));
|
dispatch(
|
||||||
|
setRectSelectionThunk({
|
||||||
|
selection: blockIds,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[container.scrollLeft, container.scrollTop, dispatch, getIntersectedBlockIds, isDragging]
|
[container.scrollLeft, container.scrollTop, dispatch, docId, getIntersectedBlockIds, isDragging]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraging = useCallback(
|
const handleDraging = useCallback(
|
||||||
@ -105,7 +117,12 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
|
|||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
|
||||||
dispatch(rectSelectionActions.updateSelections([]));
|
dispatch(
|
||||||
|
rectSelectionActions.updateSelections({
|
||||||
|
docId,
|
||||||
|
selection: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@ -114,7 +131,7 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
|
|||||||
setDragging(false);
|
setDragging(false);
|
||||||
setRect(null);
|
setRect(null);
|
||||||
},
|
},
|
||||||
[dispatch, isDragging, updateSelctionsByPoint]
|
[dispatch, docId, isDragging, updateSelctionsByPoint]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { useAppSelector } from '$app/stores/store';
|
|
||||||
import { RegionGrid } from '$app/utils/region_grid';
|
import { RegionGrid } from '$app/utils/region_grid';
|
||||||
|
import { useSubscribeDocument, useSubscribeDocumentData } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useNodesRect(container: HTMLDivElement) {
|
export function useNodesRect(container: HTMLDivElement) {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const version = useVersionUpdate();
|
const version = useVersionUpdate();
|
||||||
|
|
||||||
@ -75,9 +74,7 @@ export function useNodesRect(container: HTMLDivElement) {
|
|||||||
|
|
||||||
function useVersionUpdate() {
|
function useVersionUpdate() {
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
const data = useAppSelector((state) => {
|
const data = useSubscribeDocumentData();
|
||||||
return state.document;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVersion((v) => {
|
setVersion((v) => {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions';
|
import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
@ -10,12 +9,14 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
|
|||||||
import { isPrintableKeyEvent } from '$app/utils/document/action';
|
import { isPrintableKeyEvent } from '$app/utils/document/action';
|
||||||
import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
||||||
import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
|
import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useRangeKeyDown() {
|
export function useRangeKeyDown() {
|
||||||
const rangeRef = useRangeRef();
|
const rangeRef = useRangeRef();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const interceptEvents = useMemo(
|
const interceptEvents = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -92,6 +93,7 @@ export function useRangeKeyDown() {
|
|||||||
dispatch(
|
dispatch(
|
||||||
arrowActionForRangeThunk({
|
arrowActionForRangeThunk({
|
||||||
key: e.key,
|
key: e.key,
|
||||||
|
docId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -112,7 +114,7 @@ export function useRangeKeyDown() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[controller, dispatch]
|
[controller, dispatch, docId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onKeyDownCapture = useCallback(
|
const onKeyDownCapture = useCallback(
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate';
|
import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate';
|
||||||
import { deleteNodeThunk } from '$app_reducers/document/async-actions';
|
import { deleteNodeThunk } from '$app_reducers/document/async-actions';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useBlockMenu(id: string) {
|
export function useBlockMenu(id: string) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const handleDuplicate = useCallback(async () => {
|
const handleDuplicate = useCallback(async () => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
|
@ -1,14 +1,37 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { List } from '@mui/material';
|
|
||||||
import { ContentCopy, Delete } from '@mui/icons-material';
|
import { ContentCopy, Delete } from '@mui/icons-material';
|
||||||
import MenuItem from './MenuItem';
|
import MenuItem from '../_shared/MenuItem';
|
||||||
import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks';
|
import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks';
|
||||||
import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto';
|
import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
import { selectOptionByUpDown } from '$app/utils/document/menu';
|
||||||
|
|
||||||
|
enum BlockMenuOption {
|
||||||
|
Duplicate = 'Duplicate',
|
||||||
|
Delete = 'Delete',
|
||||||
|
TurnInto = 'TurnInto',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
operate?: () => Promise<void>;
|
||||||
|
title?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
key: BlockMenuOption;
|
||||||
|
openNextMenu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
||||||
const { handleDelete, handleDuplicate } = useBlockMenu(id);
|
const { handleDelete, handleDuplicate } = useBlockMenu(id);
|
||||||
|
const [subMenuOpened, setSubMenuOpened] = useState(false);
|
||||||
|
const [hovered, setHovered] = useState<BlockMenuOption | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hovered !== BlockMenuOption.TurnInto) {
|
||||||
|
setSubMenuOpened(false);
|
||||||
|
}
|
||||||
|
}, [hovered]);
|
||||||
|
|
||||||
const [turnIntoOptionHovered, setTurnIntoOptionHorvered] = useState<boolean>(false);
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
async ({ operate }: { operate: () => Promise<void> }) => {
|
async ({ operate }: { operate: () => Promise<void> }) => {
|
||||||
await operate();
|
await operate();
|
||||||
@ -17,52 +40,119 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
|||||||
[onClose]
|
[onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const options: Option[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
operate: () => {
|
||||||
|
return handleClick({ operate: handleDelete });
|
||||||
|
},
|
||||||
|
title: 'Delete',
|
||||||
|
icon: <Delete />,
|
||||||
|
key: BlockMenuOption.Delete,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operate: () => {
|
||||||
|
return handleClick({ operate: handleDuplicate });
|
||||||
|
},
|
||||||
|
title: 'Duplicate',
|
||||||
|
icon: <ContentCopy />,
|
||||||
|
key: BlockMenuOption.Duplicate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: BlockMenuOption.TurnInto,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleClick, handleDelete, handleDuplicate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
const isUp = e.key === Keyboard.keys.UP;
|
||||||
|
const isDown = e.key === Keyboard.keys.DOWN;
|
||||||
|
const isLeft = e.key === Keyboard.keys.LEFT;
|
||||||
|
const isRight = e.key === Keyboard.keys.RIGHT;
|
||||||
|
const isEnter = e.key === Keyboard.keys.ENTER;
|
||||||
|
|
||||||
|
const isArrow = isUp || isDown || isLeft || isRight;
|
||||||
|
|
||||||
|
if (!isArrow && !isEnter) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isEnter) {
|
||||||
|
if (hovered) {
|
||||||
|
const option = options.find((option) => option.key === hovered);
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
option.operate?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsKeys = options.map((option) => option.key);
|
||||||
|
|
||||||
|
if (isUp || isDown) {
|
||||||
|
const nextKey = selectOptionByUpDown(isUp, hovered, optionsKeys);
|
||||||
|
const nextOption = options.find((option) => option.key === nextKey);
|
||||||
|
|
||||||
|
setHovered(nextOption?.key ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLeft || isRight) {
|
||||||
|
if (hovered === BlockMenuOption.TurnInto) {
|
||||||
|
setSubMenuOpened(isRight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hovered, onClose, options]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<div
|
||||||
|
tabIndex={1}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Prevent the block from being selected.
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/** Delete option in the BlockMenu. */}
|
<div className={'p-2'}>
|
||||||
<MenuItem
|
<TextField autoFocus label='Search' placeholder='Search actions...' variant='standard' />
|
||||||
title='Delete'
|
</div>
|
||||||
icon={<Delete />}
|
{options.map((option) => {
|
||||||
onClick={() =>
|
if (option.key === BlockMenuOption.TurnInto) {
|
||||||
handleClick({
|
return (
|
||||||
operate: handleDelete,
|
<BlockMenuTurnInto
|
||||||
})
|
key={option.key}
|
||||||
|
onHovered={() => {
|
||||||
|
setHovered(BlockMenuOption.TurnInto);
|
||||||
|
setSubMenuOpened(true);
|
||||||
|
}}
|
||||||
|
menuOpened={subMenuOpened}
|
||||||
|
isHovered={hovered === BlockMenuOption.TurnInto}
|
||||||
|
onClose={() => setSubMenuOpened(false)}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
onHover={(isHovered) => {
|
|
||||||
if (isHovered) {
|
return (
|
||||||
setTurnIntoOptionHorvered(false);
|
<MenuItem
|
||||||
}
|
key={option.key}
|
||||||
}}
|
title={option.title}
|
||||||
/>
|
icon={option.icon}
|
||||||
{/** Duplicate option in the BlockMenu. */}
|
isHovered={hovered === option.key}
|
||||||
<MenuItem
|
onClick={option.operate}
|
||||||
title='Duplicate'
|
onHover={() => {
|
||||||
icon={<ContentCopy />}
|
setHovered(option.key);
|
||||||
onClick={() =>
|
setSubMenuOpened(false);
|
||||||
handleClick({
|
}}
|
||||||
operate: handleDuplicate,
|
/>
|
||||||
})
|
);
|
||||||
}
|
})}
|
||||||
onHover={(isHovered) => {
|
</div>
|
||||||
if (isHovered) {
|
|
||||||
setTurnIntoOptionHorvered(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/** Turn Into option in the BlockMenu. */}
|
|
||||||
<BlockMenuTurnInto
|
|
||||||
onHovered={() => setTurnIntoOptionHorvered(true)}
|
|
||||||
isHovered={turnIntoOptionHovered}
|
|
||||||
onClose={onClose}
|
|
||||||
id={id}
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { MouseEvent, useRef } from 'react';
|
||||||
import { ArrowRight, Transform } from '@mui/icons-material';
|
import { ArrowRight, Transform } from '@mui/icons-material';
|
||||||
import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
|
import MenuItem from '$app/components/document/_shared/MenuItem';
|
||||||
import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
|
import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
|
||||||
|
|
||||||
function BlockMenuTurnInto({
|
function BlockMenuTurnInto({
|
||||||
@ -8,28 +8,27 @@ function BlockMenuTurnInto({
|
|||||||
onClose,
|
onClose,
|
||||||
onHovered,
|
onHovered,
|
||||||
isHovered,
|
isHovered,
|
||||||
|
menuOpened,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onHovered: () => void;
|
onHovered: (e: MouseEvent) => void;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
|
menuOpened: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const open = isHovered && menuOpened && Boolean(ref.current);
|
||||||
const open = isHovered && Boolean(anchorEl);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
ref={ref}
|
||||||
title='Turn into'
|
title='Turn into'
|
||||||
|
isHovered={isHovered}
|
||||||
icon={<Transform />}
|
icon={<Transform />}
|
||||||
extra={<ArrowRight />}
|
extra={<ArrowRight />}
|
||||||
onHover={(hovered, event) => {
|
onHover={(e) => {
|
||||||
if (hovered) {
|
onHovered(e);
|
||||||
onHovered();
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TurnIntoPopover
|
<TurnIntoPopover
|
||||||
@ -46,7 +45,7 @@ function BlockMenuTurnInto({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
anchorEl={anchorEl}
|
anchorEl={ref.current}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'center',
|
vertical: 'center',
|
||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
|
@ -3,23 +3,28 @@ import { useAppDispatch } from '@/appflowy_app/stores/store';
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
const headingBlockTopOffset: Record<number, number> = {
|
const headingBlockTopOffset: Record<number, number> = {
|
||||||
1: 7,
|
1: 7,
|
||||||
2: 5,
|
2: 5,
|
||||||
3: 4,
|
3: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||||
const [nodeId, setHoverNodeId] = useState<string | null>(null);
|
const [nodeId, setHoverNodeId] = useState<string | null>(null);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [style, setStyle] = useState<React.CSSProperties>({});
|
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
|
|
||||||
if (!el || !nodeId) return;
|
if (!el || !nodeId) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const node = getBlock(nodeId);
|
const node = getBlock(docId, nodeId);
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
setStyle({
|
setStyle({
|
||||||
opacity: '0',
|
opacity: '0',
|
||||||
@ -31,6 +36,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
|||||||
|
|
||||||
if (node.type === BlockType.HeadingBlock) {
|
if (node.type === BlockType.HeadingBlock) {
|
||||||
const nodeData = node.data as HeadingBlockData;
|
const nodeData = node.data as HeadingBlockData;
|
||||||
|
|
||||||
top = headingBlockTopOffset[nodeData.level];
|
top = headingBlockTopOffset[nodeData.level];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,11 +47,12 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [dispatch, nodeId]);
|
}, [dispatch, docId, nodeId]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
const { clientX, clientY } = e;
|
const { clientX, clientY } = e;
|
||||||
const id = getNodeIdByPoint(clientX, clientY);
|
const id = getNodeIdByPoint(clientX, clientY);
|
||||||
|
|
||||||
setHoverNodeId(id);
|
setHoverNodeId(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -69,6 +76,7 @@ function getNodeIdByPoint(x: number, y: number) {
|
|||||||
el: Element;
|
el: Element;
|
||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
viewportNodes.forEach((el) => {
|
viewportNodes.forEach((el) => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
@ -104,6 +112,7 @@ const origin: {
|
|||||||
horizontal: 'left',
|
horizontal: 'left',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePopover() {
|
export function usePopover() {
|
||||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
@ -123,7 +132,6 @@ export function usePopover() {
|
|||||||
onClose,
|
onClose,
|
||||||
open,
|
open,
|
||||||
handleOpen,
|
handleOpen,
|
||||||
disableAutoFocus: true,
|
|
||||||
...origin,
|
...origin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
|
||||||
|
|
||||||
function MenuItem({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
onClick,
|
|
||||||
extra,
|
|
||||||
onHover,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
extra?: React.ReactNode;
|
|
||||||
onHover?: (isHovered: boolean, event: React.MouseEvent<HTMLDivElement>) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemButton
|
|
||||||
onMouseEnter={(e) => onHover?.(true, e)}
|
|
||||||
onMouseLeave={(e) => onHover?.(false, e)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClick?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemIcon>{icon}</ListItemIcon>
|
|
||||||
<ListItemText primary={title} />
|
|
||||||
{extra}
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MenuItem;
|
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
|
import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
|
||||||
import Portal from '../BlockPortal';
|
import Portal from '../BlockPortal';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
@ -9,13 +9,16 @@ import BlockMenu from './BlockMenu';
|
|||||||
import ToolbarButton from './ToolbarButton';
|
import ToolbarButton from './ToolbarButton';
|
||||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||||
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
|
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const { nodeId, style, ref } = useBlockSideToolbar({ container });
|
const { nodeId, style, ref } = useBlockSideToolbar({ container });
|
||||||
const isDragging = useAppSelector((state) => state.documentRange.isDragging || state.documentRectSelection.isDragging);
|
const isDragging = useAppSelector(
|
||||||
|
(state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
|
||||||
|
);
|
||||||
const { handleOpen, ...popoverProps } = usePopover();
|
const { handleOpen, ...popoverProps } = usePopover();
|
||||||
|
|
||||||
// prevent popover from showing when anchorEl is not in DOM
|
// prevent popover from showing when anchorEl is not in DOM
|
||||||
@ -60,7 +63,12 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
|
|||||||
tooltip={'Click to open Menu'}
|
tooltip={'Click to open Menu'}
|
||||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (!nodeId) return;
|
if (!nodeId) return;
|
||||||
dispatch(rectSelectionActions.setSelectionById(nodeId));
|
dispatch(
|
||||||
|
rectSelectionActions.setSelectionById({
|
||||||
|
docId,
|
||||||
|
blockId: nodeId,
|
||||||
|
})
|
||||||
|
);
|
||||||
handleOpen(e);
|
handleOpen(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useContext, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
|
import MenuItem from '$app/components/document/_shared/MenuItem';
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
@ -12,15 +12,36 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
SafetyDivider,
|
SafetyDivider,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { List } from '@mui/material';
|
import {
|
||||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
BlockData,
|
||||||
|
BlockType,
|
||||||
|
SlashCommandGroup,
|
||||||
|
SlashCommandOption,
|
||||||
|
SlashCommandOptionKey,
|
||||||
|
} from '$app/interfaces/document';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
|
import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||||
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
import { selectOptionByUpDown } from '$app/utils/document/menu';
|
||||||
|
|
||||||
function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: () => void; searchText?: string }) {
|
function BlockSlashMenu({
|
||||||
|
id,
|
||||||
|
onClose,
|
||||||
|
searchText,
|
||||||
|
hoverOption,
|
||||||
|
container,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
searchText?: string;
|
||||||
|
hoverOption?: SlashCommandOption;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
}) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { docId, controller } = useSubscribeDocument();
|
||||||
const handleInsert = useCallback(
|
const handleInsert = useCallback(
|
||||||
async (type: BlockType, data?: BlockData<any>) => {
|
async (type: BlockType, data?: BlockData<any>) => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
@ -39,108 +60,245 @@ function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: ()
|
|||||||
[controller, dispatch, id, onClose]
|
[controller, dispatch, id, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const optionColumns = useMemo(
|
const options: (SlashCommandOption & {
|
||||||
() => [
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
group: SlashCommandGroup;
|
||||||
|
})[] = useMemo(
|
||||||
|
() =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.TEXT,
|
||||||
type: BlockType.TextBlock,
|
type: BlockType.TextBlock,
|
||||||
title: 'Text',
|
title: 'Text',
|
||||||
icon: <TextFields />,
|
icon: <TextFields />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.HEADING_1,
|
||||||
type: BlockType.HeadingBlock,
|
type: BlockType.HeadingBlock,
|
||||||
title: 'Heading 1',
|
title: 'Heading 1',
|
||||||
icon: <Title />,
|
icon: <Title />,
|
||||||
props: {
|
data: {
|
||||||
level: 1,
|
level: 1,
|
||||||
},
|
},
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.HEADING_2,
|
||||||
type: BlockType.HeadingBlock,
|
type: BlockType.HeadingBlock,
|
||||||
title: 'Heading 2',
|
title: 'Heading 2',
|
||||||
icon: <Title />,
|
icon: <Title />,
|
||||||
props: {
|
data: {
|
||||||
level: 2,
|
level: 2,
|
||||||
},
|
},
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.HEADING_3,
|
||||||
type: BlockType.HeadingBlock,
|
type: BlockType.HeadingBlock,
|
||||||
title: 'Heading 3',
|
title: 'Heading 3',
|
||||||
icon: <Title />,
|
icon: <Title />,
|
||||||
props: {
|
data: {
|
||||||
level: 3,
|
level: 3,
|
||||||
},
|
},
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.TODO,
|
||||||
type: BlockType.TodoListBlock,
|
type: BlockType.TodoListBlock,
|
||||||
title: 'To-do list',
|
title: 'To-do list',
|
||||||
icon: <Check />,
|
icon: <Check />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.BULLET,
|
||||||
type: BlockType.BulletedListBlock,
|
type: BlockType.BulletedListBlock,
|
||||||
title: 'Bulleted list',
|
title: 'Bulleted list',
|
||||||
icon: <FormatListBulleted />,
|
icon: <FormatListBulleted />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.NUMBER,
|
||||||
type: BlockType.NumberedListBlock,
|
type: BlockType.NumberedListBlock,
|
||||||
title: 'Numbered list',
|
title: 'Numbered list',
|
||||||
icon: <FormatListNumbered />,
|
icon: <FormatListNumbered />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.TOGGLE,
|
||||||
type: BlockType.ToggleListBlock,
|
type: BlockType.ToggleListBlock,
|
||||||
title: 'Toggle list',
|
title: 'Toggle list',
|
||||||
icon: <ArrowRight />,
|
icon: <ArrowRight />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: BlockType.CodeBlock,
|
key: SlashCommandOptionKey.QUOTE,
|
||||||
title: 'Code',
|
|
||||||
icon: <DataObject />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: BlockType.QuoteBlock,
|
type: BlockType.QuoteBlock,
|
||||||
title: 'Quote',
|
title: 'Quote',
|
||||||
icon: <FormatQuote />,
|
icon: <FormatQuote />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.CALLOUT,
|
||||||
type: BlockType.CalloutBlock,
|
type: BlockType.CalloutBlock,
|
||||||
title: 'Callout',
|
title: 'Callout',
|
||||||
icon: <Lightbulb />,
|
icon: <Lightbulb />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.DIVIDER,
|
||||||
type: BlockType.DividerBlock,
|
type: BlockType.DividerBlock,
|
||||||
title: 'Divider',
|
title: 'Divider',
|
||||||
icon: <SafetyDivider />,
|
icon: <SafetyDivider />,
|
||||||
|
group: SlashCommandGroup.BASIC,
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
],
|
key: SlashCommandOptionKey.CODE,
|
||||||
[]
|
type: BlockType.CodeBlock,
|
||||||
|
title: 'Code',
|
||||||
|
icon: <DataObject />,
|
||||||
|
group: SlashCommandGroup.MEDIA,
|
||||||
|
},
|
||||||
|
].filter((option) => {
|
||||||
|
if (!searchText) return true;
|
||||||
|
const match = (text: string) => {
|
||||||
|
return text.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
return match(option.title) || match(option.type);
|
||||||
|
}),
|
||||||
|
[searchText]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const optionsByGroup = useMemo(() => {
|
||||||
|
return options.reduce((acc, option) => {
|
||||||
|
if (!acc[option.group]) {
|
||||||
|
acc[option.group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[option.group].push(option);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<SlashCommandGroup, typeof options>);
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const scrollIntoOption = useCallback((option: SlashCommandOption) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const containerRect = ref.current.getBoundingClientRect();
|
||||||
|
const optionElement = document.querySelector(`#slash-item-${option.key}`);
|
||||||
|
|
||||||
|
if (!optionElement) return;
|
||||||
|
const itemRect = optionElement?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!itemRect) return;
|
||||||
|
|
||||||
|
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
|
||||||
|
optionElement.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectOptionByArrow = useCallback(
|
||||||
|
({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => {
|
||||||
|
if (!isUp && !isDown) return;
|
||||||
|
const optionsKeys = options.map((option) => String(option.key));
|
||||||
|
const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys);
|
||||||
|
const nextOption = options.find((option) => String(option.key) === nextKey);
|
||||||
|
|
||||||
|
if (!nextOption) return;
|
||||||
|
|
||||||
|
scrollIntoOption(nextOption);
|
||||||
|
dispatch(
|
||||||
|
slashCommandActions.setHoverOption({
|
||||||
|
option: nextOption,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, docId, hoverOption?.key, options, scrollIntoOption]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDownCapture = (e: KeyboardEvent) => {
|
||||||
|
const isUp = e.key === Keyboard.keys.UP;
|
||||||
|
const isDown = e.key === Keyboard.keys.DOWN;
|
||||||
|
const isEnter = e.key === Keyboard.keys.ENTER;
|
||||||
|
|
||||||
|
// if any arrow key is pressed, prevent default behavior and stop propagation
|
||||||
|
if (isUp || isDown || isEnter) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (isEnter) {
|
||||||
|
if (hoverOption) {
|
||||||
|
handleInsert(hoverOption.type, hoverOption.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOptionByArrow({
|
||||||
|
isUp,
|
||||||
|
isDown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// intercept keydown event in capture phase before it reaches the editor
|
||||||
|
container.addEventListener('keydown', handleKeyDownCapture, true);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('keydown', handleKeyDownCapture, true);
|
||||||
|
};
|
||||||
|
}, [container, handleInsert, hoverOption, selectOptionByArrow]);
|
||||||
|
|
||||||
|
const onHoverOption = useCallback(
|
||||||
|
(option: SlashCommandOption) => {
|
||||||
|
dispatch(
|
||||||
|
slashCommandActions.setHoverOption({
|
||||||
|
option: {
|
||||||
|
key: option.key,
|
||||||
|
type: option.type,
|
||||||
|
data: option.data,
|
||||||
|
},
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, docId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={'flex'}
|
className={'flex h-[100%] max-h-[40vh] w-[324px] min-w-[180px] max-w-[calc(100vw-32px)] flex-col p-1'}
|
||||||
>
|
>
|
||||||
{optionColumns.map((column, index) => (
|
<div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||||
<List key={index} className={'flex-1'}>
|
{Object.entries(optionsByGroup).map(([group, options]) => (
|
||||||
{column.map((option) => {
|
<div key={group}>
|
||||||
return (
|
<div className={'px-2 py-2 text-sm text-shade-3'}>{group}</div>
|
||||||
<MenuItem
|
<div>
|
||||||
key={option.title}
|
{options.map((option) => {
|
||||||
title={option.title}
|
return (
|
||||||
icon={option.icon}
|
<MenuItem
|
||||||
onClick={() => {
|
id={`slash-item-${option.key}`}
|
||||||
handleInsert(option.type, option.props);
|
key={option.key}
|
||||||
}}
|
title={option.title}
|
||||||
/>
|
icon={option.icon}
|
||||||
);
|
onHover={() => {
|
||||||
})}
|
onHoverOption(option);
|
||||||
</List>
|
}}
|
||||||
))}
|
isHovered={hoverOption?.key === option.key}
|
||||||
|
onClick={() => {
|
||||||
|
handleInsert(option.type, option.data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,23 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import { Op } from 'quill-delta';
|
import { Op } from 'quill-delta';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
|
||||||
|
|
||||||
export function useBlockSlash() {
|
export function useBlockSlash() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { blockId, visible, slashText } = useSubscribeSlash();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
|
||||||
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (blockId && visible) {
|
if (blockId && visible) {
|
||||||
const el = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
|
const blockEl = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
|
||||||
|
const el = blockEl.querySelector(`[role="textbox"]`) as HTMLElement;
|
||||||
|
|
||||||
if (el) {
|
if (el) {
|
||||||
setAnchorEl(el);
|
setAnchorEl(el);
|
||||||
return;
|
return;
|
||||||
@ -21,18 +28,20 @@ export function useBlockSlash() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slashText) {
|
if (!slashText) {
|
||||||
dispatch(slashCommandActions.closeSlashCommand());
|
dispatch(slashCommandActions.closeSlashCommand(docId));
|
||||||
}
|
}
|
||||||
}, [dispatch, slashText]);
|
}, [dispatch, docId, slashText]);
|
||||||
|
|
||||||
const searchText = useMemo(() => {
|
const searchText = useMemo(() => {
|
||||||
if (!slashText) return '';
|
if (!slashText) return '';
|
||||||
if (slashText[0] !== '/') return slashText;
|
if (slashText[0] !== '/') return slashText;
|
||||||
|
|
||||||
return slashText.slice(1, slashText.length);
|
return slashText.slice(1, slashText.length);
|
||||||
}, [slashText]);
|
}, [slashText]);
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
dispatch(slashCommandActions.closeSlashCommand());
|
dispatch(slashCommandActions.closeSlashCommand(docId));
|
||||||
}, [dispatch]);
|
}, [dispatch, docId]);
|
||||||
|
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
@ -42,17 +51,21 @@ export function useBlockSlash() {
|
|||||||
onClose,
|
onClose,
|
||||||
blockId,
|
blockId,
|
||||||
searchText,
|
searchText,
|
||||||
|
hoverOption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function useSubscribeSlash() {
|
|
||||||
const slashCommandState = useAppSelector((state) => state.documentSlashCommand);
|
|
||||||
|
|
||||||
const visible = useMemo(() => slashCommandState.isSlashCommand, [slashCommandState.isSlashCommand]);
|
export function useSubscribeSlash() {
|
||||||
const blockId = useMemo(() => slashCommandState.blockId, [slashCommandState.blockId]);
|
const slashCommandState = useSubscribeSlashState();
|
||||||
|
const visible = slashCommandState.isSlashCommand;
|
||||||
|
const blockId = slashCommandState.blockId;
|
||||||
|
|
||||||
const { node } = useSubscribeNode(blockId || '');
|
const { node } = useSubscribeNode(blockId || '');
|
||||||
|
|
||||||
const slashText = useMemo(() => {
|
const slashText = useMemo(() => {
|
||||||
if (!node) return '';
|
if (!node) return '';
|
||||||
const delta = node.data.delta || [];
|
const delta = node.data.delta || [];
|
||||||
|
|
||||||
return delta
|
return delta
|
||||||
.map((op: Op) => {
|
.map((op: Op) => {
|
||||||
if (typeof op.insert === 'string') {
|
if (typeof op.insert === 'string') {
|
||||||
@ -68,5 +81,6 @@ export function useSubscribeSlash() {
|
|||||||
visible,
|
visible,
|
||||||
blockId,
|
blockId,
|
||||||
slashText,
|
slashText,
|
||||||
|
hoverOption: slashCommandState.hoverOption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu';
|
import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu';
|
||||||
import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
|
import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
|
||||||
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
|
||||||
|
function BlockSlash({ container }: { container: HTMLDivElement }) {
|
||||||
|
const { blockId, open, onClose, anchorEl, searchText, hoverOption } = useBlockSlash();
|
||||||
|
|
||||||
function BlockSlash() {
|
|
||||||
const { blockId, open, onClose, anchorEl, searchText } = useBlockSlash();
|
|
||||||
if (!blockId) return null;
|
if (!blockId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -22,7 +24,13 @@ function BlockSlash() {
|
|||||||
disableAutoFocus
|
disableAutoFocus
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<BlockSlashMenu id={blockId} onClose={onClose} searchText={searchText} />
|
<BlockSlashMenu
|
||||||
|
container={container}
|
||||||
|
hoverOption={hoverOption}
|
||||||
|
id={blockId}
|
||||||
|
onClose={onClose}
|
||||||
|
searchText={searchText}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import emojiData, { EmojiMartData, Emoji } from '@emoji-mart/data';
|
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useCalloutBlock(nodeId: string) {
|
export function useCalloutBlock(nodeId: string) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||||
const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
|
const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
|
||||||
const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]);
|
const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const closeEmojiSelect = useCallback(() => {
|
const closeEmojiSelect = useCallback(() => {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
|
@ -4,12 +4,12 @@ import FormControl from '@mui/material/FormControl';
|
|||||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { supportLanguage } from '$app/constants/document/code';
|
import { supportLanguage } from '$app/constants/document/code';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
function SelectLanguage({ id, language }: { id: string; language: string }) {
|
function SelectLanguage({ id, language }: { id: string; language: string }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const onLanguageSelect = useCallback(
|
const onLanguageSelect = useCallback(
|
||||||
(event: SelectChangeEvent) => {
|
(event: SelectChangeEvent) => {
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents';
|
import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents';
|
||||||
import { enterActionForBlockThunk } from '$app_reducers/document/async-actions';
|
import { enterActionForBlockThunk } from '$app_reducers/document/async-actions';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useKeyDown(id: string) {
|
export function useKeyDown(id: string) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const commonKeyEvents = useCommonKeyEvents(id);
|
const commonKeyEvents = useCommonKeyEvents(id);
|
||||||
const customEvents = useMemo(() => {
|
const customEvents = useMemo(() => {
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlock>) {
|
export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlock>) {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
// Find the last index of the previous blocks
|
// Find the last index of the previous blocks
|
||||||
const prevNumberedIndex = useAppSelector((state) => {
|
const prevNumberedIndex = useAppSelector((state) => {
|
||||||
const nodes = state['document'].nodes;
|
const documentState = state['document'][docId];
|
||||||
const children = state['document'].children;
|
const nodes = documentState.nodes;
|
||||||
|
const children = documentState.children;
|
||||||
// The parent must be existed
|
// The parent must be existed
|
||||||
const parent = nodes[node.parent!];
|
const parent = nodes[node.parent!];
|
||||||
const siblings = children[parent.children];
|
const siblings = children[parent.children];
|
||||||
|
@ -17,7 +17,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
|
|||||||
<BlockSideToolbar container={container} />
|
<BlockSideToolbar container={container} />
|
||||||
<TextActionMenu container={container} />
|
<TextActionMenu container={container} />
|
||||||
<BlockSelection container={container} />
|
<BlockSelection container={container} />
|
||||||
<BlockSlash />
|
<BlockSlash container={container} />
|
||||||
<LinkEditPopover />
|
<LinkEditPopover />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
import { DocumentData } from '$app/interfaces/document';
|
import { DocumentData } from '$app/interfaces/document';
|
||||||
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
|
||||||
import { useParseTree } from './Tree.hooks';
|
|
||||||
|
|
||||||
export function useRoot({ documentData }: { documentData: DocumentData }) {
|
export function useRoot({ documentData }: { documentData: DocumentData }) {
|
||||||
const { rootId } = documentData;
|
const { rootId } = documentData;
|
||||||
|
|
||||||
useParseTree(documentData);
|
|
||||||
|
|
||||||
const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId);
|
const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { DocumentData } from '$app/interfaces/document';
|
|
||||||
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
|
||||||
import { documentActions } from '$app/stores/reducers/document/slice';
|
|
||||||
|
|
||||||
export function useParseTree(documentData: DocumentData) {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(documentActions.create(documentData));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dispatch(documentActions.clear());
|
|
||||||
};
|
|
||||||
}, [documentData]);
|
|
||||||
}
|
|
@ -1,12 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { calcToolbarPosition } from '$app/utils/document/toolbar';
|
import { calcToolbarPosition } from '$app/utils/document/toolbar';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
import { getNode } from '$app/utils/document/node';
|
import { getNode } from '$app/utils/document/node';
|
||||||
import { debounce } from '$app/utils/tool';
|
import { debounce } from '$app/utils/tool';
|
||||||
|
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
|
||||||
export function useMenuStyle(container: HTMLDivElement) {
|
export function useMenuStyle(container: HTMLDivElement) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const id = useAppSelector((state) => state.documentRange.caret?.id);
|
|
||||||
|
const caret = useSubscribeCaret();
|
||||||
|
const id = caret?.id;
|
||||||
|
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
|
|
||||||
const reCalculatePosition = useCallback(() => {
|
const reCalculatePosition = useCallback(() => {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { useMenuStyle } from './index.hooks';
|
import { useMenuStyle } from './index.hooks';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
|
||||||
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
|
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
|
||||||
import BlockPortal from '$app/components/document/BlockPortal';
|
import BlockPortal from '$app/components/document/BlockPortal';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
|
||||||
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
||||||
const { ref, id } = useMenuStyle(container);
|
const { ref, id } = useMenuStyle(container);
|
||||||
@ -28,7 +28,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
||||||
const range = useAppSelector((state) => state.documentRange);
|
const range = useSubscribeRanges();
|
||||||
const canShow = useMemo(() => {
|
const canShow = useMemo(() => {
|
||||||
const { isDragging, focus, anchor, ranges, caret } = range;
|
const { isDragging, focus, anchor, ranges, caret } = range;
|
||||||
// don't show if dragging
|
// don't show if dragging
|
||||||
|
@ -8,12 +8,13 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
|||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
|
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const focusId = useAppSelector((state) => state.documentRange.focus?.id || '');
|
const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
|
||||||
const { node: focusNode } = useSubscribeNode(focusId);
|
const { node: focusNode } = useSubscribeNode(focusId);
|
||||||
|
|
||||||
const [isActive, setIsActive] = React.useState(false);
|
const [isActive, setIsActive] = React.useState(false);
|
||||||
@ -33,9 +34,14 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
|
|
||||||
const isFormatActive = useCallback(async () => {
|
const isFormatActive = useCallback(async () => {
|
||||||
if (!focusNode) return false;
|
if (!focusNode) return false;
|
||||||
const { payload: isActive } = await dispatch(getFormatActiveThunk(format));
|
const { payload: isActive } = await dispatch(
|
||||||
|
getFormatActiveThunk({
|
||||||
|
format,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
return !!isActive;
|
return !!isActive;
|
||||||
}, [dispatch, format, focusNode]);
|
}, [docId, dispatch, format, focusNode]);
|
||||||
|
|
||||||
const toggleFormat = useCallback(
|
const toggleFormat = useCallback(
|
||||||
async (format: TextAction) => {
|
async (format: TextAction) => {
|
||||||
@ -52,8 +58,12 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const addLink = useCallback(() => {
|
const addLink = useCallback(() => {
|
||||||
dispatch(newLinkThunk());
|
dispatch(
|
||||||
}, [dispatch]);
|
newLinkThunk({
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [dispatch, docId]);
|
||||||
|
|
||||||
const formatClick = useCallback(
|
const formatClick = useCallback(
|
||||||
(format: TextAction) => {
|
(format: TextAction) => {
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { useAppSelector } from '$app/stores/store';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
blockConfig,
|
blockConfig,
|
||||||
@ -9,9 +8,10 @@ import {
|
|||||||
} from '$app/constants/document/config';
|
} from '$app/constants/document/config';
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import { TextAction } from '$app/interfaces/document';
|
import { TextAction } from '$app/interfaces/document';
|
||||||
|
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
|
||||||
export function useTextActionMenu() {
|
export function useTextActionMenu() {
|
||||||
const range = useAppSelector((state) => state.documentRange);
|
const range = useSubscribeRanges();
|
||||||
const isSingleLine = useMemo(() => {
|
const isSingleLine = useMemo(() => {
|
||||||
return range.focus?.id === range.anchor?.id;
|
return range.focus?.id === range.anchor?.id;
|
||||||
}, [range]);
|
}, [range]);
|
||||||
|
@ -10,9 +10,10 @@ import {
|
|||||||
} from '$app_reducers/document/async-actions';
|
} from '$app_reducers/document/async-actions';
|
||||||
import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
|
import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
|
||||||
import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
|
import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useKeyDown(id: string) {
|
export function useKeyDown(id: string) {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const turnIntoEvents = useTurnIntoBlockEvents(id);
|
const turnIntoEvents = useTurnIntoBlockEvents(id);
|
||||||
const commonKeyEvents = useCommonKeyEvents(id);
|
const commonKeyEvents = useCommonKeyEvents(id);
|
||||||
|
@ -12,29 +12,30 @@ import isHotkey from 'is-hotkey';
|
|||||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
import { getDeltaText } from '$app/utils/document/delta';
|
import { getDeltaText } from '$app/utils/document/delta';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useTurnIntoBlockEvents(id: string) {
|
export function useTurnIntoBlockEvents(id: string) {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const rangeRef = useRangeRef();
|
const rangeRef = useRangeRef();
|
||||||
|
|
||||||
const getFlag = useCallback(() => {
|
const getFlag = useCallback(() => {
|
||||||
const range = rangeRef.current?.caret;
|
const range = rangeRef.current?.caret;
|
||||||
if (!range || range.id !== id) return;
|
if (!range || range.id !== id) return;
|
||||||
const node = getBlock(id);
|
const node = getBlock(docId, id);
|
||||||
const delta = new Delta(node.data.delta || []);
|
const delta = new Delta(node.data.delta || []);
|
||||||
const flag = getDeltaText(delta.slice(0, range.index));
|
return getDeltaText(delta.slice(0, range.index));
|
||||||
return flag;
|
}, [docId, id, rangeRef]);
|
||||||
}, [id, rangeRef]);
|
|
||||||
|
|
||||||
const getDeltaContent = useCallback(() => {
|
const getDeltaContent = useCallback(() => {
|
||||||
const range = rangeRef.current?.caret;
|
const range = rangeRef.current?.caret;
|
||||||
if (!range || range.id !== id) return;
|
if (!range || range.id !== id) return;
|
||||||
const node = getBlock(id);
|
const node = getBlock(docId, id);
|
||||||
const delta = new Delta(node.data.delta || []);
|
const delta = new Delta(node.data.delta || []);
|
||||||
const content = delta.slice(range.index);
|
const content = delta.slice(range.index);
|
||||||
return new Delta(content);
|
return new Delta(content);
|
||||||
}, [id, rangeRef]);
|
}, [docId, id, rangeRef]);
|
||||||
|
|
||||||
const canHandle = useCallback(
|
const canHandle = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
|
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
|
||||||
@ -171,17 +172,18 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
return isHotkey('/', e) && flag === '';
|
return isHotkey('/', e) && flag === '';
|
||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
dispatch(
|
dispatch(
|
||||||
slashCommandActions.openSlashCommand({
|
slashCommandActions.openSlashCommand({
|
||||||
blockId: id,
|
blockId: id,
|
||||||
|
docId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [canHandle, controller, dispatch, getDeltaContent, getFlag, id, spaceTriggerMap]);
|
}, [canHandle, controller, dispatch, docId, getDeltaContent, getFlag, id, spaceTriggerMap]);
|
||||||
|
|
||||||
return turnIntoBlockEvents;
|
return turnIntoBlockEvents;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
|
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
|
||||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) {
|
export function useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
const toggleCheckbox = useCallback(() => {
|
const toggleCheckbox = useCallback(() => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
void dispatch(
|
void dispatch(
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
|
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
|
||||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) {
|
export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
const toggleCollapsed = useCallback(() => {
|
const toggleCollapsed = useCallback(() => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
void dispatch(
|
void dispatch(
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import { useCallback, useContext, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { copyThunk } from '$app_reducers/document/async-actions/copyPaste';
|
import { copyThunk } from '$app_reducers/document/async-actions/copy_paste';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { BlockCopyData } from '$app/interfaces/document';
|
import { BlockCopyData } from '$app/interfaces/document';
|
||||||
import { clipboardTypes } from '$app/constants/document/copy_paste';
|
import { clipboardTypes } from '$app/constants/document/copy_paste';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useCopy(container: HTMLDivElement) {
|
export function useCopy(container: HTMLDivElement) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const handleCopyCapture = useCallback(
|
const onCopy = useCallback(
|
||||||
(e: ClipboardEvent) => {
|
(e: ClipboardEvent, isCut: boolean) => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -22,16 +22,35 @@ export function useCopy(container: HTMLDivElement) {
|
|||||||
dispatch(
|
dispatch(
|
||||||
copyThunk({
|
copyThunk({
|
||||||
setClipboardData,
|
setClipboardData,
|
||||||
|
controller,
|
||||||
|
isCut,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[controller, dispatch]
|
[controller, dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCopyCapture = useCallback(
|
||||||
|
(e: ClipboardEvent) => {
|
||||||
|
onCopy(e, false);
|
||||||
|
},
|
||||||
|
[onCopy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCutCapture = useCallback(
|
||||||
|
(e: ClipboardEvent) => {
|
||||||
|
onCopy(e, true);
|
||||||
|
},
|
||||||
|
[onCopy]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
container.addEventListener('copy', handleCopyCapture, true);
|
container.addEventListener('copy', handleCopyCapture, true);
|
||||||
|
container.addEventListener('cut', handleCutCapture, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('copy', handleCopyCapture, true);
|
container.removeEventListener('copy', handleCopyCapture, true);
|
||||||
|
container.removeEventListener('cut', handleCutCapture, true);
|
||||||
};
|
};
|
||||||
}, [container, handleCopyCapture]);
|
}, [container, handleCopyCapture, handleCutCapture]);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { useCallback, useContext, useEffect } from 'react';
|
import { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
import { pasteThunk } from '$app_reducers/document/async-actions/copy_paste';
|
||||||
import { pasteThunk } from '$app_reducers/document/async-actions/copyPaste';
|
|
||||||
import { clipboardTypes } from '$app/constants/document/copy_paste';
|
import { clipboardTypes } from '$app/constants/document/copy_paste';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function usePaste(container: HTMLDivElement) {
|
export function usePaste(container: HTMLDivElement) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const handlePasteCapture = useCallback(
|
const handlePasteCapture = useCallback(
|
||||||
(e: ClipboardEvent) => {
|
(e: ClipboardEvent) => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
|
@ -6,16 +6,19 @@ import {
|
|||||||
rightActionForBlockThunk,
|
rightActionForBlockThunk,
|
||||||
upDownActionForBlockThunk,
|
upDownActionForBlockThunk,
|
||||||
} from '$app_reducers/document/async-actions';
|
} from '$app_reducers/document/async-actions';
|
||||||
import { useContext, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
|
import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
|
||||||
import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
import Delta from 'quill-delta';
|
||||||
|
|
||||||
export function useCommonKeyEvents(id: string) {
|
export function useCommonKeyEvents(id: string) {
|
||||||
const { focused, caretRef } = useFocused(id);
|
const { focused, caretRef } = useFocused(id);
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const commonKeyEvents = useMemo(() => {
|
const commonKeyEvents = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -42,7 +45,7 @@ export function useCommonKeyEvents(id: string) {
|
|||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatch(upDownActionForBlockThunk({ id }));
|
dispatch(upDownActionForBlockThunk({ docId, id }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -52,27 +55,29 @@ export function useCommonKeyEvents(id: string) {
|
|||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatch(upDownActionForBlockThunk({ id, down: true }));
|
dispatch(upDownActionForBlockThunk({ docId, id, down: true }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// handle left arrow key and no other key is pressed
|
// handle left arrow key and no other key is pressed
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
return isHotkey(Keyboard.keys.LEFT, e);
|
return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
|
||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatch(leftActionForBlockThunk({ id }));
|
dispatch(leftActionForBlockThunk({ docId, id }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// handle right arrow key and no other key is pressed
|
// handle right arrow key and no other key is pressed
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
return isHotkey(Keyboard.keys.RIGHT, e);
|
const block = getBlock(docId, id);
|
||||||
|
const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
|
||||||
|
return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
|
||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatch(rightActionForBlockThunk({ id }));
|
dispatch(rightActionForBlockThunk({ docId, id }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -91,6 +96,6 @@ export function useCommonKeyEvents(id: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [caretRef, controller, dispatch, focused, id]);
|
}, [docId, caretRef, controller, dispatch, focused, id]);
|
||||||
return commonKeyEvents;
|
return commonKeyEvents;
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
|
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
|
export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const penddingRef = useRef(false);
|
const penddingRef = useRef(false);
|
||||||
const { node } = useSubscribeNode(id);
|
const { node } = useSubscribeNode(id);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import { RangeStatic } from 'quill';
|
import { RangeStatic } from 'quill';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { rangeActions } from '$app_reducers/document/slice';
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
useSubscribeDecorate,
|
useSubscribeDecorate,
|
||||||
} from '$app/components/document/_shared/SubscribeSelection.hooks';
|
} from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
|
import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useSelection(id: string) {
|
export function useSelection(id: string) {
|
||||||
const rangeRef = useRangeRef();
|
const rangeRef = useRangeRef();
|
||||||
@ -15,12 +16,13 @@ export function useSelection(id: string) {
|
|||||||
const decorateProps = useSubscribeDecorate(id);
|
const decorateProps = useSubscribeDecorate(id);
|
||||||
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
|
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const storeRange = useCallback(
|
const storeRange = useCallback(
|
||||||
(range: RangeStatic) => {
|
(range: RangeStatic) => {
|
||||||
dispatch(storeRangeThunk({ id, range }));
|
dispatch(storeRangeThunk({ id, range, docId }));
|
||||||
},
|
},
|
||||||
[id, dispatch]
|
[docId, id, dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSelectionChange = useCallback(
|
const onSelectionChange = useCallback(
|
||||||
@ -28,14 +30,17 @@ export function useSelection(id: string) {
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
id,
|
docId,
|
||||||
index: range.index,
|
caret: {
|
||||||
length: range.length,
|
id,
|
||||||
|
index: range.index,
|
||||||
|
length: range.length,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
storeRange(range);
|
storeRange(range);
|
||||||
},
|
},
|
||||||
[id, dispatch, storeRange]
|
[docId, id, dispatch, storeRange]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -44,6 +49,7 @@ export function useSelection(id: string) {
|
|||||||
setSelection(undefined);
|
setSelection(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection({
|
setSelection({
|
||||||
index: focusCaret.index,
|
index: focusCaret.index,
|
||||||
length: focusCaret.length,
|
length: focusCaret.length,
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import React, { forwardRef, MouseEvent, useMemo } from 'react';
|
||||||
|
import { ListItemButton } from '@mui/material';
|
||||||
|
|
||||||
|
const MenuItem = forwardRef(function (
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
extra,
|
||||||
|
onHover,
|
||||||
|
isHovered,
|
||||||
|
className,
|
||||||
|
iconSize,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
desc?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
isHovered?: boolean;
|
||||||
|
onHover?: (e: MouseEvent) => void;
|
||||||
|
iconSize?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
|
) {
|
||||||
|
const imgSize = useMemo(() => iconSize || { width: 50, height: 50 }, [iconSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} ref={ref} id={id}>
|
||||||
|
<ListItemButton
|
||||||
|
sx={{
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
selected={isHovered}
|
||||||
|
onMouseEnter={(e) => onHover?.(e)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mr-2 flex h-[${imgSize.height}px] w-[${imgSize.width}px] items-center justify-center rounded border border-shade-5`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className={'flex flex-1 flex-col'}>
|
||||||
|
<div className={'text-sm'}>{title}</div>
|
||||||
|
{desc && (
|
||||||
|
<div
|
||||||
|
className={'font-normal text-shade-4'}
|
||||||
|
style={{
|
||||||
|
fontSize: '0.85em',
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>{extra}</div>
|
||||||
|
</ListItemButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MenuItem;
|
@ -9,7 +9,7 @@ import {
|
|||||||
indent,
|
indent,
|
||||||
outdent,
|
outdent,
|
||||||
} from '$app/utils/document/slate_editor';
|
} from '$app/utils/document/slate_editor';
|
||||||
import { focusNodeByIndex, getWordIndices } from '$app/utils/document/node';
|
import { focusNodeByIndex } from '$app/utils/document/node';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
@ -139,39 +139,6 @@ export function useEditor({
|
|||||||
[editor]
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is a hack to fix the bug that the editor decoration is updated cause selection is lost
|
|
||||||
const onMouseDownCapture = useCallback(
|
|
||||||
(event: React.MouseEvent) => {
|
|
||||||
editor.deselect();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const range = document.caretRangeFromPoint(event.clientX, event.clientY);
|
|
||||||
if (!range) return;
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection) return;
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
);
|
|
||||||
|
|
||||||
// double click to select a word
|
|
||||||
// This is a hack to fix the bug that mouse down event deselect the selection
|
|
||||||
const onDoubleClick = useCallback((event: React.MouseEvent) => {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection) return;
|
|
||||||
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
||||||
if (!range) return;
|
|
||||||
const node = range.startContainer;
|
|
||||||
const offset = range.startOffset;
|
|
||||||
const wordIndices = getWordIndices(node, offset);
|
|
||||||
if (wordIndices.length === 0) return;
|
|
||||||
range.setStart(node, wordIndices[0].startIndex);
|
|
||||||
range.setEnd(node, wordIndices[0].endIndex);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const isFocused = ReactEditor.isFocused(editor);
|
const isFocused = ReactEditor.isFocused(editor);
|
||||||
@ -179,12 +146,25 @@ export function useEditor({
|
|||||||
isFocused && editor.deselect();
|
isFocused && editor.deselect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
||||||
if (!slateSelection) return;
|
if (!slateSelection) return;
|
||||||
|
|
||||||
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
||||||
|
|
||||||
|
// why we didn't use slate api to change selection?
|
||||||
|
// because the slate must be focused before change selection,
|
||||||
|
// but then it will trigger selection change, and the selection is not what we want
|
||||||
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
|
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
Transforms.select(editor, slateSelection);
|
Transforms.select(editor, slateSelection);
|
||||||
|
} else {
|
||||||
|
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (window.getSelection()?.type === 'None' && !editor.selection) {
|
||||||
|
Transforms.select(editor, slateSelection);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [editor, selection]);
|
}, [editor, selection]);
|
||||||
|
|
||||||
@ -197,7 +177,5 @@ export function useEditor({
|
|||||||
ref,
|
ref,
|
||||||
onKeyDown: onKeyDownRewrite,
|
onKeyDown: onKeyDownRewrite,
|
||||||
onBlur,
|
onBlur,
|
||||||
onMouseDownCapture,
|
|
||||||
onDoubleClick,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
|
||||||
|
export function useSubscribeDocument() {
|
||||||
|
const controller = useContext(DocumentControllerContext);
|
||||||
|
const docId = controller.documentId;
|
||||||
|
return {
|
||||||
|
docId,
|
||||||
|
controller,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscribeDocumentData() {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
const data = useAppSelector((state) => {
|
||||||
|
return state.document[docId];
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
|
export function useSubscribeLinkPopover() {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const linkPopover = useAppSelector((state) => {
|
||||||
|
return state.documentLinkPopover[docId];
|
||||||
|
});
|
||||||
|
|
||||||
|
return linkPopover;
|
||||||
|
}
|
@ -1,22 +1,30 @@
|
|||||||
import { store, useAppSelector } from '@/appflowy_app/stores/store';
|
import { store, useAppSelector } from '@/appflowy_app/stores/store';
|
||||||
import { createContext, useEffect, useMemo, useRef } from 'react';
|
import { createContext, useMemo } from 'react';
|
||||||
import { Node } from '$app/interfaces/document';
|
import { Node } from '$app/interfaces/document';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe node information
|
* Subscribe node information
|
||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
export function useSubscribeNode(id: string) {
|
export function useSubscribeNode(id: string) {
|
||||||
const node = useAppSelector<Node>((state) => state.document.nodes[id]);
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const node = useAppSelector<Node>((state) => {
|
||||||
|
const documentState = state.document[docId];
|
||||||
|
return documentState?.nodes[id];
|
||||||
|
});
|
||||||
|
|
||||||
const childIds = useAppSelector<string[] | undefined>((state) => {
|
const childIds = useAppSelector<string[] | undefined>((state) => {
|
||||||
const childrenId = state.document.nodes[id]?.children;
|
const documentState = state.document[docId];
|
||||||
|
if (!documentState) return;
|
||||||
|
const childrenId = documentState.nodes[id]?.children;
|
||||||
if (!childrenId) return;
|
if (!childrenId) return;
|
||||||
return state.document.children[childrenId];
|
return documentState.children[childrenId];
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSelected = useAppSelector<boolean>((state) => {
|
const isSelected = useAppSelector<boolean>((state) => {
|
||||||
return state.documentRectSelection.selection.includes(id) || false;
|
return state.documentRectSelection[docId]?.selection.includes(id) || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the node and its children
|
// Memoize the node and its children
|
||||||
@ -32,8 +40,8 @@ export function useSubscribeNode(id: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlock(id: string) {
|
export function getBlock(docId: string, id: string) {
|
||||||
return store.getState().document.nodes[id];
|
return store.getState().document[docId].nodes[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeIdContext = createContext<string>('');
|
export const NodeIdContext = createContext<string>('');
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
|
||||||
|
export function useSubscribeRectRange() {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
const rectRange = useAppSelector((state) => {
|
||||||
|
return state.documentRectSelection[docId];
|
||||||
|
});
|
||||||
|
return rectRange;
|
||||||
|
}
|
@ -1,15 +1,18 @@
|
|||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useSubscribeDecorate(id: string) {
|
export function useSubscribeDecorate(id: string) {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const decorateSelection = useAppSelector((state) => {
|
const decorateSelection = useAppSelector((state) => {
|
||||||
return state.documentRange.ranges[id];
|
return state.documentRange[docId]?.ranges[id];
|
||||||
});
|
});
|
||||||
|
|
||||||
const linkDecorateSelection = useAppSelector((state) => {
|
const linkDecorateSelection = useAppSelector((state) => {
|
||||||
const linkPopoverState = state.documentLinkPopover;
|
const linkPopoverState = state.documentLinkPopover[docId];
|
||||||
if (!linkPopoverState.open || linkPopoverState.id !== id) return;
|
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
|
||||||
return {
|
return {
|
||||||
selection: linkPopoverState.selection,
|
selection: linkPopoverState.selection,
|
||||||
placeholder: linkPopoverState.title,
|
placeholder: linkPopoverState.title,
|
||||||
@ -22,9 +25,11 @@ export function useSubscribeDecorate(id: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function useFocused(id: string) {
|
export function useFocused(id: string) {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const caretRef = useRef<RangeStatic>();
|
const caretRef = useRef<RangeStatic>();
|
||||||
const focusCaret = useAppSelector((state) => {
|
const focusCaret = useAppSelector((state) => {
|
||||||
const currentCaret = state.documentRange.caret;
|
const currentCaret = state.documentRange[docId]?.caret;
|
||||||
caretRef.current = currentCaret;
|
caretRef.current = currentCaret;
|
||||||
if (currentCaret?.id === id) {
|
if (currentCaret?.id === id) {
|
||||||
return currentCaret;
|
return currentCaret;
|
||||||
@ -44,10 +49,32 @@ export function useFocused(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRangeRef() {
|
export function useRangeRef() {
|
||||||
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const rangeRef = useRef<RangeState>();
|
const rangeRef = useRef<RangeState>();
|
||||||
useAppSelector((state) => {
|
useAppSelector((state) => {
|
||||||
const currentRange = state.documentRange;
|
const currentRange = state.documentRange[docId];
|
||||||
rangeRef.current = currentRange;
|
rangeRef.current = currentRange;
|
||||||
});
|
});
|
||||||
return rangeRef;
|
return rangeRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSubscribeRanges() {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const rangeState = useAppSelector((state) => {
|
||||||
|
return state.documentRange[docId];
|
||||||
|
});
|
||||||
|
|
||||||
|
return rangeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscribeCaret() {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const caret = useAppSelector((state) => {
|
||||||
|
return state.documentRange[docId]?.caret;
|
||||||
|
});
|
||||||
|
|
||||||
|
return caret;
|
||||||
|
}
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
|
||||||
|
export function useSubscribeSlashState() {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const slashCommandState = useAppSelector((state) => {
|
||||||
|
return state.documentSlashCommand[docId];
|
||||||
|
});
|
||||||
|
|
||||||
|
return slashCommandState;
|
||||||
|
}
|
@ -5,20 +5,21 @@ import { DeleteOutline, Done } from '@mui/icons-material';
|
|||||||
import EditLink from '$app/components/document/_shared/TextLink/EditLink';
|
import EditLink from '$app/components/document/_shared/TextLink/EditLink';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
|
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { updateLinkThunk } from '$app_reducers/document/async-actions';
|
|
||||||
import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
|
import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||||
import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
|
import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useSubscribeLinkPopover } from '$app/components/document/_shared/SubscribeLinkPopover.hooks';
|
||||||
|
|
||||||
function LinkEditPopover() {
|
function LinkEditPopover() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { docId, controller } = useSubscribeDocument();
|
||||||
const popoverState = useAppSelector((state) => state.documentLinkPopover);
|
|
||||||
|
const popoverState = useSubscribeLinkPopover();
|
||||||
const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
|
const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
dispatch(linkPopoverActions.closeLinkPopover());
|
dispatch(linkPopoverActions.closeLinkPopover(docId));
|
||||||
}, [dispatch]);
|
}, [dispatch, docId]);
|
||||||
|
|
||||||
const onExited = useCallback(() => {
|
const onExited = useCallback(() => {
|
||||||
if (!id || !selection) return;
|
if (!id || !selection) return;
|
||||||
@ -28,31 +29,39 @@ function LinkEditPopover() {
|
|||||||
};
|
};
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setRange({
|
rangeActions.setRange({
|
||||||
|
docId,
|
||||||
id,
|
id,
|
||||||
rangeStatic: newSelection,
|
rangeStatic: newSelection,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
id,
|
docId,
|
||||||
...newSelection,
|
caret: {
|
||||||
|
id,
|
||||||
|
...newSelection,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [id, selection, title, dispatch]);
|
}, [docId, id, selection, title, dispatch]);
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(newVal: { href?: string; title: string }) => {
|
(newVal: { href?: string; title: string }) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (newVal.title === title && newVal.href === href) return;
|
if (newVal.title === title && newVal.href === href) return;
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updateLinkThunk({
|
linkPopoverActions.updateLinkPopover({
|
||||||
id,
|
docId,
|
||||||
href: newVal.href,
|
linkState: {
|
||||||
title: newVal.title,
|
id,
|
||||||
|
href: newVal.href,
|
||||||
|
title: newVal.title,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[dispatch, href, id, title]
|
[docId, dispatch, href, id, title]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDone = useCallback(async () => {
|
const onDone = useCallback(async () => {
|
||||||
|
@ -4,6 +4,7 @@ import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.
|
|||||||
import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
|
import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { linkPopoverActions } from '$app_reducers/document/slice';
|
import { linkPopoverActions } from '$app_reducers/document/slice';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
function TextLink({
|
function TextLink({
|
||||||
getSelection,
|
getSelection,
|
||||||
@ -22,6 +23,7 @@ function TextLink({
|
|||||||
const blockId = useContext(NodeIdContext);
|
const blockId = useContext(NodeIdContext);
|
||||||
const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
|
const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const onEdit = useCallback(() => {
|
const onEdit = useCallback(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
@ -31,18 +33,21 @@ function TextLink({
|
|||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
dispatch(
|
dispatch(
|
||||||
linkPopoverActions.setLinkPopover({
|
linkPopoverActions.setLinkPopover({
|
||||||
anchorPosition: {
|
docId,
|
||||||
top: rect.top + rect.height,
|
linkState: {
|
||||||
left: rect.left + rect.width / 2,
|
anchorPosition: {
|
||||||
|
top: rect.top + rect.height,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
},
|
||||||
|
id: blockId,
|
||||||
|
selection,
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
open: true,
|
||||||
},
|
},
|
||||||
id: blockId,
|
|
||||||
selection,
|
|
||||||
title,
|
|
||||||
href,
|
|
||||||
open: true,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [blockId, dispatch, getSelection, href, ref, title]);
|
}, [blockId, dispatch, docId, getSelection, href, ref, title]);
|
||||||
if (!blockId) return null;
|
if (!blockId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
|
import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
|
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const turnIntoBlock = useCallback(
|
const turnIntoBlock = useCallback(
|
||||||
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
|
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { BlockType } from '$app/interfaces/document';
|
import { BlockType, SlashCommandOptionKey } from '$app/interfaces/document';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@ -14,10 +14,20 @@ import {
|
|||||||
Functions,
|
Functions,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||||
import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
|
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks';
|
import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks';
|
||||||
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
import MenuItem from '$app/components/document/_shared/MenuItem';
|
||||||
|
import { selectOptionByUpDown } from '$app/utils/document/menu';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
key: SlashCommandOptionKey;
|
||||||
|
type: BlockType;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
onClick?: (type: BlockType, isSelected: boolean) => void;
|
||||||
|
}
|
||||||
const TurnIntoPopover = ({
|
const TurnIntoPopover = ({
|
||||||
id,
|
id,
|
||||||
onClose,
|
onClose,
|
||||||
@ -28,21 +38,18 @@ const TurnIntoPopover = ({
|
|||||||
} & PopoverProps) => {
|
} & PopoverProps) => {
|
||||||
const { node } = useSubscribeNode(id);
|
const { node } = useSubscribeNode(id);
|
||||||
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
|
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
|
||||||
|
const [hovered, setHovered] = React.useState<SlashCommandOptionKey>();
|
||||||
|
|
||||||
const options: {
|
const options: Option[] = useMemo(
|
||||||
type: BlockType;
|
|
||||||
title: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
selected?: boolean;
|
|
||||||
onClick?: (type: BlockType, isSelected: boolean) => void;
|
|
||||||
}[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.TEXT,
|
||||||
type: BlockType.TextBlock,
|
type: BlockType.TextBlock,
|
||||||
title: 'Text',
|
title: 'Text',
|
||||||
icon: <TextFields />,
|
icon: <TextFields />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.HEADING_1,
|
||||||
type: BlockType.HeadingBlock,
|
type: BlockType.HeadingBlock,
|
||||||
title: 'Heading 1',
|
title: 'Heading 1',
|
||||||
icon: <Title />,
|
icon: <Title />,
|
||||||
@ -52,6 +59,7 @@ const TurnIntoPopover = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.HEADING_2,
|
||||||
type: BlockType.HeadingBlock,
|
type: BlockType.HeadingBlock,
|
||||||
title: 'Heading 2',
|
title: 'Heading 2',
|
||||||
icon: <Title />,
|
icon: <Title />,
|
||||||
@ -61,6 +69,7 @@ const TurnIntoPopover = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.HEADING_3,
|
||||||
type: BlockType.HeadingBlock,
|
type: BlockType.HeadingBlock,
|
||||||
title: 'Heading 3',
|
title: 'Heading 3',
|
||||||
icon: <Title />,
|
icon: <Title />,
|
||||||
@ -70,36 +79,43 @@ const TurnIntoPopover = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.TODO,
|
||||||
type: BlockType.TodoListBlock,
|
type: BlockType.TodoListBlock,
|
||||||
title: 'To-do list',
|
title: 'To-do list',
|
||||||
icon: <Check />,
|
icon: <Check />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.BULLET,
|
||||||
type: BlockType.BulletedListBlock,
|
type: BlockType.BulletedListBlock,
|
||||||
title: 'Bulleted list',
|
title: 'Bulleted list',
|
||||||
icon: <FormatListBulleted />,
|
icon: <FormatListBulleted />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.NUMBER,
|
||||||
type: BlockType.NumberedListBlock,
|
type: BlockType.NumberedListBlock,
|
||||||
title: 'Numbered list',
|
title: 'Numbered list',
|
||||||
icon: <FormatListNumbered />,
|
icon: <FormatListNumbered />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.TOGGLE,
|
||||||
type: BlockType.ToggleListBlock,
|
type: BlockType.ToggleListBlock,
|
||||||
title: 'Toggle list',
|
title: 'Toggle list',
|
||||||
icon: <ArrowRight />,
|
icon: <ArrowRight />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.CODE,
|
||||||
type: BlockType.CodeBlock,
|
type: BlockType.CodeBlock,
|
||||||
title: 'Code',
|
title: 'Code',
|
||||||
icon: <DataObject />,
|
icon: <DataObject />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.QUOTE,
|
||||||
type: BlockType.QuoteBlock,
|
type: BlockType.QuoteBlock,
|
||||||
title: 'Quote',
|
title: 'Quote',
|
||||||
icon: <FormatQuote />,
|
icon: <FormatQuote />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: SlashCommandOptionKey.CALLOUT,
|
||||||
type: BlockType.CalloutBlock,
|
type: BlockType.CalloutBlock,
|
||||||
title: 'Callout',
|
title: 'Callout',
|
||||||
icon: <Lightbulb />,
|
icon: <Lightbulb />,
|
||||||
@ -113,24 +129,90 @@ const TurnIntoPopover = ({
|
|||||||
[node?.data?.level, turnIntoHeading]
|
[node?.data?.level, turnIntoHeading]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getSelected = useCallback(
|
||||||
|
(option: Option) => {
|
||||||
|
return option.type === node.type && option.selected !== false;
|
||||||
|
},
|
||||||
|
[node?.type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = useCallback(
|
||||||
|
(option: Option) => {
|
||||||
|
const isSelected = getSelected(option);
|
||||||
|
|
||||||
|
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
|
||||||
|
},
|
||||||
|
[getSelected, turnIntoBlock]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const isUp = e.key === Keyboard.keys.UP;
|
||||||
|
const isDown = e.key === Keyboard.keys.DOWN;
|
||||||
|
const isEnter = e.key === Keyboard.keys.ENTER;
|
||||||
|
const isLeft = e.key === Keyboard.keys.LEFT;
|
||||||
|
|
||||||
|
if (isLeft) {
|
||||||
|
onClose?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUp && !isDown && !isEnter) return;
|
||||||
|
if (isEnter) {
|
||||||
|
const option = options.find((option) => option.key === hovered);
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
onClick(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKey = selectOptionByUpDown(
|
||||||
|
isUp,
|
||||||
|
String(hovered),
|
||||||
|
options.map((option) => String(option.key))
|
||||||
|
);
|
||||||
|
const nextOption = options.find((option) => String(option.key) === nextKey);
|
||||||
|
|
||||||
|
setHovered(nextOption?.key);
|
||||||
|
},
|
||||||
|
[hovered, onClick, onClose, options]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
document.addEventListener('keydown', onKeyDown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [onKeyDown, props.open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover disableAutoFocus={true} onClose={onClose} {...props}>
|
<Popover disableAutoFocus={true} onClose={onClose} {...props}>
|
||||||
{options.map((option) => {
|
<div className={'min-w-[220px]'}>
|
||||||
const isSelected = option.type === node.type && option.selected !== false;
|
{options.map((option) => {
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={'w-[100%]'}
|
iconSize={{
|
||||||
key={option.title}
|
width: 20,
|
||||||
onClick={() =>
|
height: 20,
|
||||||
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected)
|
}}
|
||||||
}
|
icon={option.icon}
|
||||||
>
|
title={option.title}
|
||||||
<ListItemIcon>{option.icon}</ListItemIcon>
|
isHovered={hovered === option.key}
|
||||||
<ListItemText>{option.title}</ListItemText>
|
extra={getSelected(option) ? <Check /> : null}
|
||||||
<ListItemIcon>{isSelected ? <Check /> : null}</ListItemIcon>
|
className={'w-[100%]'}
|
||||||
</MenuItem>
|
key={option.title}
|
||||||
);
|
onClick={() => onClick(option)}
|
||||||
})}
|
></MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useContext, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { Keyboard } from '@/appflowy_app/constants/document/keyboard';
|
import { Keyboard } from '@/appflowy_app/constants/document/keyboard';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
export function useUndoRedo(container: HTMLDivElement) {
|
export function useUndoRedo(container: HTMLDivElement) {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const { controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const onUndo = useCallback(() => {
|
const onUndo = useCallback(() => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
|
@ -115,9 +115,39 @@ export interface DocumentState {
|
|||||||
// map of block id to children block ids
|
// map of block id to children block ids
|
||||||
children: Record<string, string[]>;
|
children: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlashCommandState {
|
export interface SlashCommandState {
|
||||||
isSlashCommand: boolean;
|
isSlashCommand: boolean;
|
||||||
blockId?: string;
|
blockId?: string;
|
||||||
|
hoverOption?: SlashCommandOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SlashCommandOptionKey {
|
||||||
|
TEXT,
|
||||||
|
PAGE,
|
||||||
|
TODO,
|
||||||
|
BULLET,
|
||||||
|
NUMBER,
|
||||||
|
TOGGLE,
|
||||||
|
CODE,
|
||||||
|
EQUATION,
|
||||||
|
QUOTE,
|
||||||
|
CALLOUT,
|
||||||
|
DIVIDER,
|
||||||
|
HEADING_1,
|
||||||
|
HEADING_2,
|
||||||
|
HEADING_3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandOption {
|
||||||
|
type: BlockType;
|
||||||
|
data?: BlockData<any>;
|
||||||
|
key: SlashCommandOptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SlashCommandGroup {
|
||||||
|
BASIC = 'Basic',
|
||||||
|
MEDIA = 'Media',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RectSelectionState {
|
export interface RectSelectionState {
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { DocumentBlockJSON, DocumentData, Node } from '@/appflowy_app/interfaces/document';
|
import { DocumentData, Node } from '@/appflowy_app/interfaces/document';
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
import { DocumentBackendService } from './document_bd_svc';
|
import { DocumentBackendService } from './document_bd_svc';
|
||||||
import {
|
import {
|
||||||
FlowyError,
|
|
||||||
BlockActionPB,
|
BlockActionPB,
|
||||||
DocEventPB,
|
DocEventPB,
|
||||||
BlockActionTypePB,
|
BlockActionTypePB,
|
||||||
@ -17,15 +16,13 @@ import { get } from '@/appflowy_app/utils/tool';
|
|||||||
import { blockPB2Node } from '$app/utils/document/block';
|
import { blockPB2Node } from '$app/utils/document/block';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
|
|
||||||
export const DocumentControllerContext = createContext<DocumentController | null>(null);
|
|
||||||
|
|
||||||
export class DocumentController {
|
export class DocumentController {
|
||||||
private readonly backendService: DocumentBackendService;
|
private readonly backendService: DocumentBackendService;
|
||||||
private readonly observer: DocumentObserver;
|
private readonly observer: DocumentObserver;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly documentId: string,
|
public readonly documentId: string,
|
||||||
private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void
|
private onDocChange?: (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => void
|
||||||
) {
|
) {
|
||||||
this.backendService = new DocumentBackendService(documentId);
|
this.backendService = new DocumentBackendService(documentId);
|
||||||
this.observer = new DocumentObserver(documentId);
|
this.observer = new DocumentObserver(documentId);
|
||||||
@ -37,14 +34,17 @@ export class DocumentController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const document = await this.backendService.open();
|
const document = await this.backendService.open();
|
||||||
|
|
||||||
if (document.ok) {
|
if (document.ok) {
|
||||||
const nodes: DocumentData['nodes'] = {};
|
const nodes: DocumentData['nodes'] = {};
|
||||||
|
|
||||||
get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => {
|
get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => {
|
||||||
Object.assign(nodes, {
|
Object.assign(nodes, {
|
||||||
[block.id]: blockPB2Node(block),
|
[block.id]: blockPB2Node(block),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const children: Record<string, string[]> = {};
|
const children: Record<string, string[]> = {};
|
||||||
|
|
||||||
get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
|
get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
|
||||||
children[key] = child.children;
|
children[key] = child.children;
|
||||||
});
|
});
|
||||||
@ -60,6 +60,7 @@ export class DocumentController {
|
|||||||
|
|
||||||
applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
|
applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
|
||||||
Log.debug('applyActions', actions);
|
Log.debug('applyActions', actions);
|
||||||
|
if (actions.length === 0) return;
|
||||||
await this.backendService.applyActions(actions);
|
await this.backendService.applyActions(actions);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -109,21 +110,25 @@ export class DocumentController {
|
|||||||
|
|
||||||
canUndo = async () => {
|
canUndo = async () => {
|
||||||
const result = await this.backendService.canUndoRedo();
|
const result = await this.backendService.canUndoRedo();
|
||||||
|
|
||||||
return result.ok && result.val.can_undo;
|
return result.ok && result.val.can_undo;
|
||||||
};
|
};
|
||||||
|
|
||||||
canRedo = async () => {
|
canRedo = async () => {
|
||||||
const result = await this.backendService.canUndoRedo();
|
const result = await this.backendService.canUndoRedo();
|
||||||
|
|
||||||
return result.ok && result.val.can_redo;
|
return result.ok && result.val.can_redo;
|
||||||
};
|
};
|
||||||
|
|
||||||
undo = async () => {
|
undo = async () => {
|
||||||
const result = await this.backendService.undo();
|
const result = await this.backendService.undo();
|
||||||
|
|
||||||
return result.ok && result.val.is_success;
|
return result.ok && result.val.is_success;
|
||||||
};
|
};
|
||||||
|
|
||||||
redo = async () => {
|
redo = async () => {
|
||||||
const result = await this.backendService.redo();
|
const result = await this.backendService.redo();
|
||||||
|
|
||||||
return result.ok && result.val.is_success;
|
return result.ok && result.val.is_success;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -152,14 +157,17 @@ export class DocumentController {
|
|||||||
|
|
||||||
private composeDelta = (node: Node) => {
|
private composeDelta = (node: Node) => {
|
||||||
const delta = node.data.delta;
|
const delta = node.data.delta;
|
||||||
|
|
||||||
if (!delta) {
|
if (!delta) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use yjs to compose delta, it can make sure the delta is correct
|
// we use yjs to compose delta, it can make sure the delta is correct
|
||||||
// for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
|
// for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
|
||||||
// but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
|
// but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
|
||||||
const ydoc = new Y.Doc();
|
const ydoc = new Y.Doc();
|
||||||
const ytext = ydoc.getText(node.id);
|
const ytext = ydoc.getText(node.id);
|
||||||
|
|
||||||
ytext.applyDelta(delta);
|
ytext.applyDelta(delta);
|
||||||
Object.assign(node.data, { delta: ytext.toDelta() });
|
Object.assign(node.data, { delta: ytext.toDelta() });
|
||||||
};
|
};
|
||||||
@ -172,6 +180,7 @@ export class DocumentController {
|
|||||||
events.forEach((blockEvent) => {
|
events.forEach((blockEvent) => {
|
||||||
blockEvent.event.forEach((_payload) => {
|
blockEvent.event.forEach((_payload) => {
|
||||||
this.onDocChange?.({
|
this.onDocChange?.({
|
||||||
|
docId: this.documentId,
|
||||||
isRemote: is_remote,
|
isRemote: is_remote,
|
||||||
data: _payload,
|
data: _payload,
|
||||||
});
|
});
|
||||||
@ -179,3 +188,5 @@ export class DocumentController {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DocumentControllerContext = createContext<DocumentController>(new DocumentController(''));
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { DocumentState } from '$app/interfaces/document';
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
export const deleteNodeThunk = createAsyncThunk(
|
export const deleteNodeThunk = createAsyncThunk(
|
||||||
'document/deleteNode',
|
'document/deleteNode',
|
||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as { document: DocumentState };
|
const state = getState() as RootState;
|
||||||
const node = state.document.nodes[id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
await controller.applyActions([controller.getDeleteAction(node)]);
|
await controller.applyActions([controller.getDeleteAction(node)]);
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,29 @@
|
|||||||
import { DocumentState } from '$app/interfaces/document';
|
|
||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { newBlock } from '$app/utils/document/block';
|
|
||||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||||
import { getDuplicateActions } from '$app/utils/document/action';
|
import { getDuplicateActions } from '$app/utils/document/action';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
export const duplicateBelowNodeThunk = createAsyncThunk(
|
export const duplicateBelowNodeThunk = createAsyncThunk(
|
||||||
'document/duplicateBelowNode',
|
'document/duplicateBelowNode',
|
||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as { document: DocumentState };
|
const state = getState() as RootState;
|
||||||
const node = state.document.nodes[id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
if (!node || !node.parent) return;
|
if (!node || !node.parent) return;
|
||||||
const duplicateActions = getDuplicateActions(id, node.parent, state.document, controller);
|
const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
|
||||||
|
|
||||||
if (!duplicateActions) return;
|
if (!duplicateActions) return;
|
||||||
await controller.applyActions(duplicateActions.actions);
|
await controller.applyActions(duplicateActions.actions);
|
||||||
|
|
||||||
dispatch(rectSelectionActions.updateSelections([duplicateActions.newNodeId]));
|
dispatch(
|
||||||
|
rectSelectionActions.updateSelections({
|
||||||
|
docId,
|
||||||
|
selection: [duplicateActions.newNodeId],
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { getPrevNodeId } from '$app/utils/document/block';
|
import { getPrevNodeId } from '$app/utils/document/block';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* indent node
|
* indent node
|
||||||
@ -16,24 +17,26 @@ export const indentNodeThunk = createAsyncThunk(
|
|||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = getState() as RootState;
|
||||||
const node = state.nodes[id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
if (!node.parent) return;
|
if (!node.parent) return;
|
||||||
|
|
||||||
// get prev node
|
// get prev node
|
||||||
const prevNodeId = getPrevNodeId(state, id);
|
const prevNodeId = getPrevNodeId(docState, id);
|
||||||
if (!prevNodeId) return;
|
if (!prevNodeId) return;
|
||||||
const newParentNode = state.nodes[prevNodeId];
|
const newParentNode = docState.nodes[prevNodeId];
|
||||||
// check if prev node is allowed to have children
|
// check if prev node is allowed to have children
|
||||||
const config = blockConfig[newParentNode.type];
|
const config = blockConfig[newParentNode.type];
|
||||||
if (!config.canAddChild) return;
|
if (!config.canAddChild) return;
|
||||||
|
|
||||||
// check if prev node has children and get last child for new prev node
|
// check if prev node has children and get last child for new prev node
|
||||||
const newParentChildren = state.children[newParentNode.children];
|
const newParentChildren = docState.children[newParentNode.children];
|
||||||
const newPrevId = newParentChildren[newParentChildren.length - 1];
|
const newPrevId = newParentChildren[newParentChildren.length - 1];
|
||||||
|
|
||||||
const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
|
const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
|
||||||
const childrenNodes = state.children[node.children].map((id) => state.nodes[id]);
|
const childrenNodes = docState.children[node.children].map((id) => docState.nodes[id]);
|
||||||
const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
|
const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
|
||||||
|
|
||||||
await controller.applyActions([moveAction, ...moveChildrenActions]);
|
await controller.applyActions([moveAction, ...moveChildrenActions]);
|
||||||
|
@ -2,6 +2,7 @@ import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
|
|||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { newBlock } from '$app/utils/document/block';
|
import { newBlock } from '$app/utils/document/block';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
export const insertAfterNodeThunk = createAsyncThunk(
|
export const insertAfterNodeThunk = createAsyncThunk(
|
||||||
'document/insertAfterNode',
|
'document/insertAfterNode',
|
||||||
@ -12,10 +13,13 @@ export const insertAfterNodeThunk = createAsyncThunk(
|
|||||||
data = {
|
data = {
|
||||||
delta: [],
|
delta: [],
|
||||||
},
|
},
|
||||||
|
id,
|
||||||
} = payload;
|
} = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as { document: DocumentState };
|
const state = getState() as RootState;
|
||||||
const node = state.document.nodes[payload.id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const parentId = node.parent;
|
const parentId = node.parent;
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
|
@ -4,6 +4,7 @@ import { DocumentState } from '$app/interfaces/document';
|
|||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { getMoveChildrenActions } from '$app/utils/document/action';
|
import { getMoveChildrenActions } from '$app/utils/document/action';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge two blocks
|
* Merge two blocks
|
||||||
@ -16,9 +17,11 @@ export const mergeDeltaThunk = createAsyncThunk(
|
|||||||
async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { sourceId, targetId, controller } = payload;
|
const { sourceId, targetId, controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = getState() as RootState;
|
||||||
const target = state.nodes[targetId];
|
const docId = controller.documentId;
|
||||||
const source = state.nodes[sourceId];
|
const docState = state.document[docId];
|
||||||
|
const target = docState.nodes[targetId];
|
||||||
|
const source = docState.nodes[sourceId];
|
||||||
if (!target || !source) return;
|
if (!target || !source) return;
|
||||||
const targetDelta = new Delta(target.data.delta);
|
const targetDelta = new Delta(target.data.delta);
|
||||||
const sourceDelta = new Delta(source.data.delta);
|
const sourceDelta = new Delta(source.data.delta);
|
||||||
@ -34,7 +37,7 @@ export const mergeDeltaThunk = createAsyncThunk(
|
|||||||
|
|
||||||
const actions = [updateAction];
|
const actions = [updateAction];
|
||||||
// move children
|
// move children
|
||||||
const children = state.children[source.children].map((id) => state.nodes[id]);
|
const children = docState.children[source.children].map((id) => docState.nodes[id]);
|
||||||
const moveActions = getMoveChildrenActions({
|
const moveActions = getMoveChildrenActions({
|
||||||
controller,
|
controller,
|
||||||
children,
|
children,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { DocumentState } from '$app/interfaces/document';
|
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* outdent node
|
* outdent node
|
||||||
@ -17,16 +17,18 @@ export const outdentNodeThunk = createAsyncThunk(
|
|||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = getState() as RootState;
|
||||||
const node = state.nodes[id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
const parentId = node.parent;
|
const parentId = node.parent;
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
const ancestorId = state.nodes[parentId].parent;
|
const ancestorId = docState.nodes[parentId].parent;
|
||||||
if (!ancestorId) return;
|
if (!ancestorId) return;
|
||||||
|
|
||||||
const parent = state.nodes[parentId];
|
const parent = docState.nodes[parentId];
|
||||||
const index = state.children[parent.children].indexOf(id);
|
const index = docState.children[parent.children].indexOf(id);
|
||||||
const nextSiblingIds = state.children[parent.children].slice(index + 1);
|
const nextSiblingIds = docState.children[parent.children].slice(index + 1);
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
const moveAction = controller.getMoveAction(node, ancestorId, parentId);
|
const moveAction = controller.getMoveAction(node, ancestorId, parentId);
|
||||||
@ -35,7 +37,7 @@ export const outdentNodeThunk = createAsyncThunk(
|
|||||||
const config = blockConfig[node.type];
|
const config = blockConfig[node.type];
|
||||||
if (nextSiblingIds.length > 0) {
|
if (nextSiblingIds.length > 0) {
|
||||||
if (config.canAddChild) {
|
if (config.canAddChild) {
|
||||||
const children = state.children[node.children];
|
const children = docState.children[node.children];
|
||||||
let lastChildId: string | null = null;
|
let lastChildId: string | null = null;
|
||||||
const lastIndex = children.length - 1;
|
const lastIndex = children.length - 1;
|
||||||
if (lastIndex >= 0) {
|
if (lastIndex >= 0) {
|
||||||
@ -43,12 +45,12 @@ export const outdentNodeThunk = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
const moveChildrenActions = nextSiblingIds
|
const moveChildrenActions = nextSiblingIds
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((id) => controller.getMoveAction(state.nodes[id], node.id, lastChildId));
|
.map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
} else {
|
} else {
|
||||||
const moveChildrenActions = nextSiblingIds
|
const moveChildrenActions = nextSiblingIds
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((id) => controller.getMoveAction(state.nodes[id], ancestorId, node.id));
|
.map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,17 @@ import { BlockData, DocumentState } from '$app/interfaces/document';
|
|||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import Delta, { Op } from 'quill-delta';
|
import Delta, { Op } from 'quill-delta';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
export const updateNodeDeltaThunk = createAsyncThunk(
|
export const updateNodeDeltaThunk = createAsyncThunk(
|
||||||
'document/updateNodeDelta',
|
'document/updateNodeDelta',
|
||||||
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, delta, controller } = payload;
|
const { id, delta, controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = getState() as RootState;
|
||||||
const node = state.nodes[id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
|
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
|
||||||
if (diffDelta.ops.length === 0) return;
|
if (diffDelta.ops.length === 0) return;
|
||||||
|
|
||||||
@ -34,8 +37,10 @@ export const updateNodeDataThunk = createAsyncThunk<
|
|||||||
>('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
|
>('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
|
||||||
const { id, data, controller } = payload;
|
const { id, data, controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = getState() as RootState;
|
||||||
const node = state.nodes[id];
|
const docId = controller.documentId;
|
||||||
|
const docState = state.document[docId];
|
||||||
|
const node = docState.nodes[id];
|
||||||
|
|
||||||
const newData = { ...node.data, ...data };
|
const newData = { ...node.data, ...data };
|
||||||
|
|
||||||
|
@ -17,13 +17,17 @@ import { rangeActions } from '$app_reducers/document/slice';
|
|||||||
export const copyThunk = createAsyncThunk<
|
export const copyThunk = createAsyncThunk<
|
||||||
void,
|
void,
|
||||||
{
|
{
|
||||||
|
isCut?: boolean;
|
||||||
|
controller: DocumentController;
|
||||||
setClipboardData: (data: BlockCopyData) => void;
|
setClipboardData: (data: BlockCopyData) => void;
|
||||||
}
|
}
|
||||||
>('document/copy', async (payload, thunkAPI) => {
|
>('document/copy', async (payload, thunkAPI) => {
|
||||||
const { getState } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const { setClipboardData } = payload;
|
const { setClipboardData, isCut = false, controller } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const { document, documentRange } = state;
|
const document = state.document[docId];
|
||||||
|
const documentRange = state.documentRange[docId];
|
||||||
const startAndEndIds = getStartAndEndIdsByRange(documentRange);
|
const startAndEndIds = getStartAndEndIdsByRange(documentRange);
|
||||||
if (startAndEndIds.length === 0) return;
|
if (startAndEndIds.length === 0) return;
|
||||||
const result: DocumentBlockJSON[] = [];
|
const result: DocumentBlockJSON[] = [];
|
||||||
@ -70,6 +74,10 @@ export const copyThunk = createAsyncThunk<
|
|||||||
text: '',
|
text: '',
|
||||||
html: '',
|
html: '',
|
||||||
});
|
});
|
||||||
|
if (isCut) {
|
||||||
|
// delete range blocks
|
||||||
|
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,6 +102,11 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
// delete range blocks
|
// delete range blocks
|
||||||
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
||||||
|
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const docId = controller.documentId;
|
||||||
|
const document = state.document[docId];
|
||||||
|
const documentRange = state.documentRange[docId];
|
||||||
|
|
||||||
let pasteData;
|
let pasteData;
|
||||||
if (data.json) {
|
if (data.json) {
|
||||||
pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
|
pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
|
||||||
@ -103,7 +116,6 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
// TODO: implement html
|
// TODO: implement html
|
||||||
}
|
}
|
||||||
if (!pasteData) return;
|
if (!pasteData) return;
|
||||||
const { document, documentRange } = getState() as RootState;
|
|
||||||
const { caret } = documentRange;
|
const { caret } = documentRange;
|
||||||
if (!caret) return;
|
if (!caret) return;
|
||||||
const currentBlock = document.nodes[caret.id];
|
const currentBlock = document.nodes[caret.id];
|
||||||
@ -135,9 +147,12 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
// set caret to the end of the last paste block
|
// set caret to the end of the last paste block
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
id: lastPasteBlock.id,
|
docId,
|
||||||
index: new Delta(lastPasteBlock.data.delta).length(),
|
caret: {
|
||||||
length: 0,
|
id: lastPasteBlock.id,
|
||||||
|
index: new Delta(lastPasteBlock.data.delta).length(),
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -150,7 +165,11 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
length: currentBlockDelta.length() - caret.index,
|
length: currentBlockDelta.length() - caret.index,
|
||||||
});
|
});
|
||||||
|
|
||||||
let newCaret;
|
let newCaret: {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
|
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
|
||||||
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
|
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
|
||||||
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
|
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
|
||||||
@ -198,5 +217,10 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
// set caret to the end of the last paste block
|
// set caret to the end of the last paste block
|
||||||
if (!newCaret) return;
|
if (!newCaret) return;
|
||||||
|
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: newCaret,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
@ -4,42 +4,53 @@ import { TextAction } from '$app/interfaces/document';
|
|||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
|
|
||||||
export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
|
export const getFormatActiveThunk = createAsyncThunk<
|
||||||
'document/getFormatActive',
|
boolean,
|
||||||
async (format, thunkAPI) => {
|
{
|
||||||
const { getState } = thunkAPI;
|
format: TextAction;
|
||||||
const state = getState() as RootState;
|
docId: string;
|
||||||
const { document, documentRange } = state;
|
|
||||||
const { ranges } = documentRange;
|
|
||||||
const match = (delta: Delta, format: TextAction) => {
|
|
||||||
return delta.ops.every((op) => op.attributes?.[format]);
|
|
||||||
};
|
|
||||||
return Object.entries(ranges).every(([id, range]) => {
|
|
||||||
const node = document.nodes[id];
|
|
||||||
const delta = new Delta(node.data?.delta);
|
|
||||||
const index = range?.index || 0;
|
|
||||||
const length = range?.length || 0;
|
|
||||||
const rangeDelta = delta.slice(index, index + length);
|
|
||||||
|
|
||||||
return match(rangeDelta, format);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
|
||||||
|
const { getState } = thunkAPI;
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const document = state.document[docId];
|
||||||
|
const documentRange = state.documentRange[docId];
|
||||||
|
const { ranges } = documentRange;
|
||||||
|
const match = (delta: Delta, format: TextAction) => {
|
||||||
|
return delta.ops.every((op) => op.attributes?.[format]);
|
||||||
|
};
|
||||||
|
return Object.entries(ranges).every(([id, range]) => {
|
||||||
|
const node = document.nodes[id];
|
||||||
|
const delta = new Delta(node.data?.delta);
|
||||||
|
const index = range?.index || 0;
|
||||||
|
const length = range?.length || 0;
|
||||||
|
const rangeDelta = delta.slice(index, index + length);
|
||||||
|
|
||||||
|
return match(rangeDelta, format);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export const toggleFormatThunk = createAsyncThunk(
|
export const toggleFormatThunk = createAsyncThunk(
|
||||||
'document/toggleFormat',
|
'document/toggleFormat',
|
||||||
async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
|
async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const { format, controller } = payload;
|
const { format, controller } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
let isActive = payload.isActive;
|
let isActive = payload.isActive;
|
||||||
if (isActive === undefined) {
|
if (isActive === undefined) {
|
||||||
const { payload: active } = await dispatch(getFormatActiveThunk(format));
|
const { payload: active } = await dispatch(
|
||||||
|
getFormatActiveThunk({
|
||||||
|
format,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
isActive = !!active;
|
isActive = !!active;
|
||||||
}
|
}
|
||||||
const formatValue = isActive ? undefined : true;
|
const formatValue = isActive ? undefined : true;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const { document } = state;
|
const document = state.document[docId];
|
||||||
const { ranges } = state.documentRange;
|
const documentRange = state.documentRange[docId];
|
||||||
|
const { ranges } = documentRange;
|
||||||
|
|
||||||
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
|
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
|
||||||
const newOps = delta.ops.map((op) => {
|
const newOps = delta.ops.map((op) => {
|
||||||
|
@ -2,4 +2,4 @@ export * from './blocks';
|
|||||||
export * from './turn_to';
|
export * from './turn_to';
|
||||||
export * from './keydown';
|
export * from './keydown';
|
||||||
export * from './range';
|
export * from './range';
|
||||||
export { updateLinkThunk } from '$app_reducers/document/async-actions/link';
|
export * from './link';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
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 { BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document';
|
import { BlockType, RangeStatic, SplitRelationship } from '$app/interfaces/document';
|
||||||
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
|
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
|
||||||
import {
|
import {
|
||||||
findNextHasDeltaNode,
|
findNextHasDeltaNode,
|
||||||
@ -29,8 +29,9 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
|||||||
'document/backspaceDeleteActionForBlock',
|
'document/backspaceDeleteActionForBlock',
|
||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = (getState() as RootState).document[docId];
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
if (!node.parent) return;
|
if (!node.parent) return;
|
||||||
const parent = state.nodes[node.parent];
|
const parent = state.nodes[node.parent];
|
||||||
@ -60,8 +61,13 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
|||||||
controller,
|
controller,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(rangeActions.setCaret(caret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// outdent
|
// outdent
|
||||||
@ -81,11 +87,19 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
|||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const node = state.document.nodes[id];
|
const docId = controller.documentId;
|
||||||
const caret = state.documentRange.caret;
|
const documentState = state.document[docId];
|
||||||
|
const node = documentState.nodes[id];
|
||||||
|
const caret = state.documentRange[docId]?.caret;
|
||||||
if (!node || !caret || caret.id !== id) return;
|
if (!node || !caret || caret.id !== id) return;
|
||||||
|
const delta = new Delta(node.data.delta);
|
||||||
|
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
|
||||||
|
// If the node is not a text block, turn it to a text block
|
||||||
|
await dispatch(turnToTextBlockThunk({ id, controller }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeDelta = delta.slice(0, caret.index);
|
||||||
|
|
||||||
const nodeDelta = new Delta(node.data.delta).slice(0, caret.index);
|
|
||||||
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
|
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
|
||||||
|
|
||||||
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
|
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
|
||||||
@ -98,11 +112,11 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const children = state.document.children[node.children];
|
const children = documentState.children[node.children];
|
||||||
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
||||||
const moveChildrenAction = needMoveChildren
|
const moveChildrenAction = needMoveChildren
|
||||||
? controller.getMoveChildrenAction(
|
? controller.getMoveChildrenAction(
|
||||||
children.map((id) => state.document.nodes[id]),
|
children.map((id) => documentState.nodes[id]),
|
||||||
insertNodeAction.id,
|
insertNodeAction.id,
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
@ -110,12 +124,15 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
|||||||
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
|
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
id: insertNodeAction.id,
|
docId,
|
||||||
index: 0,
|
caret: {
|
||||||
length: 0,
|
id: insertNodeAction.id,
|
||||||
|
index: 0,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -131,41 +148,48 @@ export const tabActionForBlockThunk = createAsyncThunk(
|
|||||||
|
|
||||||
export const upDownActionForBlockThunk = createAsyncThunk(
|
export const upDownActionForBlockThunk = createAsyncThunk(
|
||||||
'document/upActionForBlock',
|
'document/upActionForBlock',
|
||||||
async (payload: { id: string; down?: boolean }, thunkAPI) => {
|
async (payload: { docId: string; id: string; down?: boolean }, thunkAPI) => {
|
||||||
const { id, down } = payload;
|
const { docId, id, down } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const documentState = state.document[docId];
|
||||||
|
const rangeState = state.documentRange[docId];
|
||||||
const caret = rangeState.caret;
|
const caret = rangeState.caret;
|
||||||
const node = state.document.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
if (!node || !caret || id !== caret.id) return;
|
if (!node || !caret || id !== caret.id) return;
|
||||||
|
|
||||||
let newCaret;
|
let newCaret;
|
||||||
|
|
||||||
if (down) {
|
if (down) {
|
||||||
newCaret = transformToNextLineCaret(state.document, caret);
|
newCaret = transformToNextLineCaret(documentState, caret);
|
||||||
} else {
|
} else {
|
||||||
newCaret = transformToPrevLineCaret(state.document, caret);
|
newCaret = transformToPrevLineCaret(documentState, caret);
|
||||||
}
|
}
|
||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: newCaret,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const leftActionForBlockThunk = createAsyncThunk(
|
export const leftActionForBlockThunk = createAsyncThunk(
|
||||||
'document/leftActionForBlock',
|
'document/leftActionForBlock',
|
||||||
async (payload: { id: string }, thunkAPI) => {
|
async (payload: { docId: string; id: string }, thunkAPI) => {
|
||||||
const { id } = payload;
|
const { id, docId } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const documentState = state.document[docId];
|
||||||
|
const rangeState = state.documentRange[docId];
|
||||||
const caret = rangeState.caret;
|
const caret = rangeState.caret;
|
||||||
const node = state.document.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
if (!node || !caret || id !== caret.id) return;
|
if (!node || !caret || id !== caret.id) return;
|
||||||
let newCaret;
|
let newCaret: RangeStatic;
|
||||||
if (caret.length > 0) {
|
if (caret.length > 0) {
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id,
|
id,
|
||||||
@ -180,7 +204,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
|||||||
length: 0,
|
length: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const prevNode = findPrevHasDeltaNode(state.document, id);
|
const prevNode = findPrevHasDeltaNode(documentState, id);
|
||||||
if (!prevNode) return;
|
if (!prevNode) return;
|
||||||
const prevDelta = new Delta(prevNode.data.delta);
|
const prevDelta = new Delta(prevNode.data.delta);
|
||||||
newCaret = {
|
newCaret = {
|
||||||
@ -194,22 +218,28 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: newCaret,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const rightActionForBlockThunk = createAsyncThunk(
|
export const rightActionForBlockThunk = createAsyncThunk(
|
||||||
'document/rightActionForBlock',
|
'document/rightActionForBlock',
|
||||||
async (payload: { id: string }, thunkAPI) => {
|
async (payload: { id: string; docId: string }, thunkAPI) => {
|
||||||
const { id } = payload;
|
const { id, docId } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const documentState = state.document[docId];
|
||||||
|
const rangeState = state.documentRange[docId];
|
||||||
const caret = rangeState.caret;
|
const caret = rangeState.caret;
|
||||||
const node = state.document.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
if (!node || !caret || id !== caret.id) return;
|
if (!node || !caret || id !== caret.id) return;
|
||||||
let newCaret;
|
let newCaret: RangeStatic;
|
||||||
const delta = new Delta(node.data.delta);
|
const delta = new Delta(node.data.delta);
|
||||||
const deltaLength = delta.length();
|
const deltaLength = delta.length();
|
||||||
if (caret.length > 0) {
|
if (caret.length > 0) {
|
||||||
@ -227,7 +257,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
length: 0,
|
length: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const nextNode = findNextHasDeltaNode(state.document, id);
|
const nextNode = findNextHasDeltaNode(documentState, id);
|
||||||
if (!nextNode) return;
|
if (!nextNode) return;
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id: nextNode.id,
|
id: nextNode.id,
|
||||||
@ -240,9 +270,14 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
|
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
caret: newCaret,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -259,19 +294,22 @@ export const arrowActionForRangeThunk = createAsyncThunk(
|
|||||||
async (
|
async (
|
||||||
payload: {
|
payload: {
|
||||||
key: string;
|
key: string;
|
||||||
|
docId: string;
|
||||||
},
|
},
|
||||||
thunkAPI
|
thunkAPI
|
||||||
) => {
|
) => {
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
|
const { key, docId } = payload;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const documentState = state.document[docId];
|
||||||
|
const rangeState = state.documentRange[docId];
|
||||||
let caret;
|
let caret;
|
||||||
const leftCaret = getLeftCaretByRange(rangeState);
|
const leftCaret = getLeftCaretByRange(rangeState);
|
||||||
const rightCaret = getRightCaretByRange(rangeState);
|
const rightCaret = getRightCaretByRange(rangeState);
|
||||||
|
|
||||||
if (!leftCaret || !rightCaret) return;
|
if (!leftCaret || !rightCaret) return;
|
||||||
|
|
||||||
switch (payload.key) {
|
switch (key) {
|
||||||
case Keyboard.keys.LEFT:
|
case Keyboard.keys.LEFT:
|
||||||
caret = leftCaret;
|
caret = leftCaret;
|
||||||
break;
|
break;
|
||||||
@ -279,14 +317,19 @@ export const arrowActionForRangeThunk = createAsyncThunk(
|
|||||||
caret = rightCaret;
|
caret = rightCaret;
|
||||||
break;
|
break;
|
||||||
case Keyboard.keys.UP:
|
case Keyboard.keys.UP:
|
||||||
caret = transformToPrevLineCaret(state.document, leftCaret);
|
caret = transformToPrevLineCaret(documentState, leftCaret);
|
||||||
break;
|
break;
|
||||||
case Keyboard.keys.DOWN:
|
case Keyboard.keys.DOWN:
|
||||||
caret = transformToNextLineCaret(state.document, rightCaret);
|
caret = transformToNextLineCaret(documentState, rightCaret);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!caret) return;
|
if (!caret) return;
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(rangeActions.setCaret(caret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -12,13 +12,14 @@ export const formatLinkThunk = createAsyncThunk<
|
|||||||
>('document/formatLink', async (payload, thunkAPI) => {
|
>('document/formatLink', async (payload, thunkAPI) => {
|
||||||
const { controller } = payload;
|
const { controller } = payload;
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const linkPopover = state.documentLinkPopover;
|
const documentState = state.document[docId];
|
||||||
|
const linkPopover = state.documentLinkPopover[docId];
|
||||||
if (!linkPopover) return false;
|
if (!linkPopover) return false;
|
||||||
const { selection, id, href, title = '' } = linkPopover;
|
const { selection, id, href, title = '' } = linkPopover;
|
||||||
if (!selection || !id) return false;
|
if (!selection || !id) return false;
|
||||||
const document = state.document;
|
const node = documentState.nodes[id];
|
||||||
const node = document.nodes[id];
|
|
||||||
const nodeDelta = new Delta(node.data?.delta);
|
const nodeDelta = new Delta(node.data?.delta);
|
||||||
const index = selection.index || 0;
|
const index = selection.index || 0;
|
||||||
const length = selection.length || 0;
|
const length = selection.length || 0;
|
||||||
@ -44,35 +45,22 @@ export const formatLinkThunk = createAsyncThunk<
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateLinkThunk = createAsyncThunk<
|
export const newLinkThunk = createAsyncThunk<
|
||||||
void,
|
void,
|
||||||
{
|
{
|
||||||
id: string;
|
docId: string;
|
||||||
href?: string;
|
|
||||||
title: string;
|
|
||||||
}
|
}
|
||||||
>('document/updateLink', async (payload, thunkAPI) => {
|
>('document/newLink', async ({ docId }, thunkAPI) => {
|
||||||
const { id, href, title } = payload;
|
|
||||||
const { dispatch } = thunkAPI;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
linkPopoverActions.updateLinkPopover({
|
|
||||||
id,
|
|
||||||
href,
|
|
||||||
title,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (payload, thunkAPI) => {
|
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const { documentRange, document } = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
|
const documentState = state.document[docId];
|
||||||
|
const documentRange = state.documentRange[docId];
|
||||||
|
|
||||||
const { caret } = documentRange;
|
const { caret } = documentRange;
|
||||||
if (!caret) return;
|
if (!caret) return;
|
||||||
const { index, length, id } = caret;
|
const { index, length, id } = caret;
|
||||||
|
|
||||||
const block = document.nodes[id];
|
const block = documentState.nodes[id];
|
||||||
const delta = new Delta(block.data.delta).slice(index, index + length);
|
const delta = new Delta(block.data.delta).slice(index, index + length);
|
||||||
const op = delta.ops.find((op) => op.attributes?.href);
|
const op = delta.ops.find((op) => op.attributes?.href);
|
||||||
const href = op?.attributes?.href as string;
|
const href = op?.attributes?.href as string;
|
||||||
@ -83,21 +71,24 @@ export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (pa
|
|||||||
if (!domRange) return;
|
if (!domRange) return;
|
||||||
const title = domSelection.toString();
|
const title = domSelection.toString();
|
||||||
const { top, left, height, width } = domRange.getBoundingClientRect();
|
const { top, left, height, width } = domRange.getBoundingClientRect();
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(
|
dispatch(
|
||||||
linkPopoverActions.setLinkPopover({
|
linkPopoverActions.setLinkPopover({
|
||||||
anchorPosition: {
|
docId,
|
||||||
top: top + height,
|
linkState: {
|
||||||
left: left + width / 2,
|
anchorPosition: {
|
||||||
|
top: top + height,
|
||||||
|
left: left + width / 2,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
selection: {
|
||||||
|
index,
|
||||||
|
length,
|
||||||
|
},
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
open: true,
|
||||||
},
|
},
|
||||||
id,
|
|
||||||
selection: {
|
|
||||||
index,
|
|
||||||
length,
|
|
||||||
},
|
|
||||||
title,
|
|
||||||
href,
|
|
||||||
open: true,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
|
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||||
import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
|
import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
|
||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { rangeActions, slashCommandActions } from '$app_reducers/document/slice';
|
import { rangeActions, slashCommandActions } from '$app_reducers/document/slice';
|
||||||
@ -18,8 +18,9 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
|||||||
'document/addBlockBelowClick',
|
'document/addBlockBelowClick',
|
||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = (getState() as RootState).document[docId];
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = (node.data.delta as Op[]) || [];
|
const delta = (node.data.delta as Op[]) || [];
|
||||||
@ -31,15 +32,25 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
|||||||
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
|
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
|
||||||
);
|
);
|
||||||
if (newBlockId) {
|
if (newBlockId) {
|
||||||
dispatch(rangeActions.setCaret({ id: newBlockId as string, index: 0, length: 0 }));
|
dispatch(
|
||||||
dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string }));
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: { id: newBlockId as string, index: 0, length: 0 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// if current block is empty, open slash command
|
// if current block is empty, open slash command
|
||||||
dispatch(rangeActions.setCaret({ id, index: 0, length: 0 }));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: { id, index: 0, length: 0 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
dispatch(slashCommandActions.openSlashCommand({ blockId: id }));
|
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: id }));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -63,8 +74,9 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
|
|||||||
) => {
|
) => {
|
||||||
const { id, controller, props } = payload;
|
const { id, controller, props } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const { document } = state;
|
const document = state.document[docId];
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = new Delta(node.data.delta);
|
const delta = new Delta(node.data.delta);
|
||||||
@ -111,6 +123,11 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
const newBlockId = insertNodePayload.payload as string;
|
const newBlockId = insertNodePayload.payload as string;
|
||||||
|
|
||||||
dispatch(rangeActions.setCaret({ id: newBlockId, index: 0, length: 0 }));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: { id: newBlockId, index: 0, length: 0 },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -15,6 +15,7 @@ import { RangeState, SplitRelationship } from '$app/interfaces/document';
|
|||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
|
|
||||||
interface storeRangeThunkPayload {
|
interface storeRangeThunkPayload {
|
||||||
|
docId: string;
|
||||||
id: string;
|
id: string;
|
||||||
range: {
|
range: {
|
||||||
index: number;
|
index: number;
|
||||||
@ -28,10 +29,11 @@ interface storeRangeThunkPayload {
|
|||||||
* 2. if isDragging is true, we need amend range between anchor and focus
|
* 2. if isDragging is true, we need amend range between anchor and focus
|
||||||
*/
|
*/
|
||||||
export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => {
|
export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => {
|
||||||
const { id, range } = payload;
|
const { docId, id, range } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const rangeState = state.documentRange[docId];
|
||||||
|
const documentState = state.document[docId];
|
||||||
// we need amend range between anchor and focus
|
// we need amend range between anchor and focus
|
||||||
const { anchor, focus, isDragging } = rangeState;
|
const { anchor, focus, isDragging } = rangeState;
|
||||||
if (!isDragging || !anchor || !focus) return;
|
if (!isDragging || !anchor || !focus) return;
|
||||||
@ -42,20 +44,30 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
|||||||
let anchorIndex = anchor.point.index;
|
let anchorIndex = anchor.point.index;
|
||||||
let anchorLength = anchor.point.length;
|
let anchorLength = anchor.point.length;
|
||||||
if (anchorIndex === undefined || anchorLength === undefined) {
|
if (anchorIndex === undefined || anchorLength === undefined) {
|
||||||
dispatch(rangeActions.setAnchorPointRange(range));
|
dispatch(
|
||||||
|
rangeActions.setAnchorPointRange({
|
||||||
|
...range,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
anchorIndex = range.index;
|
anchorIndex = range.index;
|
||||||
anchorLength = range.length;
|
anchorLength = range.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if anchor and focus are in the same node, we don't need to amend range
|
// if anchor and focus are in the same node, we don't need to amend range
|
||||||
if (anchor.id === id) {
|
if (anchor.id === id) {
|
||||||
dispatch(rangeActions.setRanges(ranges));
|
dispatch(
|
||||||
|
rangeActions.setRanges({
|
||||||
|
ranges,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// amend anchor range because slatejs will stop update selection when dragging quickly
|
// amend anchor range because slatejs will stop update selection when dragging quickly
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const anchorDelta = new Delta(state.document.nodes[anchor.id].data.delta);
|
const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
|
||||||
if (isForward) {
|
if (isForward) {
|
||||||
const selectedDelta = anchorDelta.slice(anchorIndex);
|
const selectedDelta = anchorDelta.slice(anchorIndex);
|
||||||
ranges[anchor.id] = {
|
ranges[anchor.id] = {
|
||||||
@ -74,9 +86,9 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
|||||||
const startId = isForward ? anchor.id : focus.id;
|
const startId = isForward ? anchor.id : focus.id;
|
||||||
const endId = isForward ? focus.id : anchor.id;
|
const endId = isForward ? focus.id : anchor.id;
|
||||||
|
|
||||||
const middleIds = getMiddleIds(state.document, startId, endId);
|
const middleIds = getMiddleIds(documentState, startId, endId);
|
||||||
middleIds.forEach((id) => {
|
middleIds.forEach((id) => {
|
||||||
const node = state.document.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
|
|
||||||
if (!node || !node.data.delta) return;
|
if (!node || !node.data.delta) return;
|
||||||
const delta = new Delta(node.data.delta);
|
const delta = new Delta(node.data.delta);
|
||||||
@ -88,7 +100,12 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
|||||||
ranges[id] = rangeStatic;
|
ranges[id] = rangeStatic;
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(rangeActions.setRanges(ranges));
|
dispatch(
|
||||||
|
rangeActions.setRanges({
|
||||||
|
ranges,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -101,9 +118,11 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
|
|||||||
'document/deleteRange',
|
'document/deleteRange',
|
||||||
async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
|
async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
|
||||||
const { controller, insertDelta } = payload;
|
const { controller, insertDelta } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const rangeState = state.documentRange[docId];
|
||||||
|
const documentState = state.document[docId];
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
// get merge actions
|
// get merge actions
|
||||||
@ -112,20 +131,25 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
|
|||||||
actions.push(...mergeActions);
|
actions.push(...mergeActions);
|
||||||
}
|
}
|
||||||
// get middle nodes
|
// get middle nodes
|
||||||
const middleIds = getMiddleIdsByRange(rangeState, state.document);
|
const middleIds = getMiddleIdsByRange(rangeState, documentState);
|
||||||
// delete middle nodes
|
// delete middle nodes
|
||||||
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
|
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
||||||
actions.push(...deleteMiddleNodesActions);
|
actions.push(...deleteMiddleNodesActions);
|
||||||
|
|
||||||
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
|
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
|
||||||
|
|
||||||
|
if (actions.length === 0) return;
|
||||||
// apply actions
|
// apply actions
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
|
||||||
// clear range
|
// clear range
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
if (caret) {
|
if (caret) {
|
||||||
dispatch(rangeActions.setCaret(caret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -144,15 +168,17 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => {
|
async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => {
|
||||||
const { controller, shiftKey } = payload;
|
const { controller, shiftKey } = payload;
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const rangeState = state.documentRange[docId];
|
||||||
|
const documentState = state.document[docId];
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
||||||
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {};
|
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
||||||
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
||||||
|
|
||||||
// get middle nodes
|
// get middle nodes
|
||||||
const middleIds = getMiddleIds(state.document, startNode.id, endNode.id);
|
const middleIds = getMiddleIds(documentState, startNode.id, endNode.id);
|
||||||
|
|
||||||
let newStartDelta = new Delta(startDelta);
|
let newStartDelta = new Delta(startDelta);
|
||||||
let caret = null;
|
let caret = null;
|
||||||
@ -174,10 +200,10 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
||||||
if (needMoveChildren) {
|
if (needMoveChildren) {
|
||||||
// filter children by delete middle ids
|
// filter children by delete middle ids
|
||||||
const children = state.document.children[startNode.children].filter((id) => middleIds?.includes(id));
|
const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
|
||||||
const moveChildrenAction = needMoveChildren
|
const moveChildrenAction = needMoveChildren
|
||||||
? controller.getMoveChildrenAction(
|
? controller.getMoveChildrenAction(
|
||||||
children.map((id) => state.document.nodes[id]),
|
children.map((id) => documentState.nodes[id]),
|
||||||
insertNodeAction.id,
|
insertNodeAction.id,
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
@ -201,16 +227,21 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// delete middle nodes
|
// delete middle nodes
|
||||||
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
|
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
||||||
actions.push(...deleteMiddleNodesActions);
|
actions.push(...deleteMiddleNodesActions);
|
||||||
|
|
||||||
// apply actions
|
// apply actions
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
|
||||||
// clear range
|
// clear range
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.initialState(docId));
|
||||||
if (caret) {
|
if (caret) {
|
||||||
dispatch(rangeActions.setCaret(caret));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block';
|
import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block';
|
||||||
import { DocumentState } from '$app/interfaces/document';
|
|
||||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
export const setRectSelectionThunk = createAsyncThunk(
|
export const setRectSelectionThunk = createAsyncThunk(
|
||||||
'document/setRectSelection',
|
'document/setRectSelection',
|
||||||
async (payload: string[], thunkAPI) => {
|
async (
|
||||||
|
payload: {
|
||||||
|
docId: string;
|
||||||
|
selection: string[];
|
||||||
|
},
|
||||||
|
thunkAPI
|
||||||
|
) => {
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const documentState = (getState() as { document: DocumentState }).document;
|
const { docId, selection } = payload;
|
||||||
|
const documentState = (getState() as RootState).document[docId];
|
||||||
const selected: Record<string, boolean> = {};
|
const selected: Record<string, boolean> = {};
|
||||||
payload.forEach((id) => {
|
selection.forEach((id) => {
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
if (!node.parent) {
|
if (!node.parent) {
|
||||||
return;
|
return;
|
||||||
@ -18,10 +25,15 @@ export const setRectSelectionThunk = createAsyncThunk(
|
|||||||
selected[node.parent] = false;
|
selected[node.parent] = false;
|
||||||
const nextNodeId = getNextNodeId(documentState, node.parent);
|
const nextNodeId = getNextNodeId(documentState, node.parent);
|
||||||
const prevNodeId = getPrevNodeId(documentState, node.parent);
|
const prevNodeId = getPrevNodeId(documentState, node.parent);
|
||||||
if ((nextNodeId && payload.includes(nextNodeId)) || (prevNodeId && payload.includes(prevNodeId))) {
|
if ((nextNodeId && selection.includes(nextNodeId)) || (prevNodeId && selection.includes(prevNodeId))) {
|
||||||
selected[node.parent] = true;
|
selected[node.parent] = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])));
|
dispatch(
|
||||||
|
rectSelectionActions.updateSelections({
|
||||||
|
docId,
|
||||||
|
selection: selection.filter((id) => selected[id]),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
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 { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
|
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { newBlock } from '$app/utils/document/block';
|
import { newBlock } from '$app/utils/document/block';
|
||||||
import { rangeActions } from '$app_reducers/document/slice';
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* transform to block
|
* transform to block
|
||||||
@ -17,8 +18,9 @@ export const turnToBlockThunk = createAsyncThunk(
|
|||||||
'document/turnToBlock',
|
'document/turnToBlock',
|
||||||
async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
|
||||||
const { id, controller, type, data } = payload;
|
const { id, controller, type, data } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const state = (getState() as RootState).document[docId];
|
||||||
|
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
if (!node.parent) return;
|
if (!node.parent) return;
|
||||||
@ -49,7 +51,12 @@ export const turnToBlockThunk = createAsyncThunk(
|
|||||||
// submit actions
|
// submit actions
|
||||||
await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
|
await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
|
||||||
// set cursor in new block
|
// set cursor in new block
|
||||||
dispatch(rangeActions.setCaret({ id: caretId, index: 0, length: 0 }));
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: { id: caretId, index: 0, length: 0 },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -64,7 +71,8 @@ export const turnToTextBlockThunk = createAsyncThunk(
|
|||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { id, controller } = payload;
|
const { id, controller } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as { document: DocumentState }).document;
|
const docId = controller.documentId;
|
||||||
|
const state = (getState() as RootState).document[docId];
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
const data = {
|
const data = {
|
||||||
delta: node.data.delta,
|
delta: node.data.delta,
|
||||||
|
@ -6,31 +6,21 @@ import {
|
|||||||
RangeState,
|
RangeState,
|
||||||
RangeStatic,
|
RangeStatic,
|
||||||
LinkPopoverState,
|
LinkPopoverState,
|
||||||
|
SlashCommandOption,
|
||||||
} from '@/appflowy_app/interfaces/document';
|
} from '@/appflowy_app/interfaces/document';
|
||||||
import { BlockEventPayloadPB } from '@/services/backend';
|
import { BlockEventPayloadPB } from '@/services/backend';
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
||||||
|
|
||||||
const initialState: DocumentState = {
|
const initialState: Record<string, DocumentState> = {};
|
||||||
nodes: {},
|
|
||||||
children: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const rectSelectionInitialState: RectSelectionState = {
|
const rectSelectionInitialState: Record<string, RectSelectionState> = {};
|
||||||
selection: [],
|
|
||||||
isDragging: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const rangeInitialState: RangeState = {
|
const rangeInitialState: Record<string, RangeState> = {};
|
||||||
isDragging: false,
|
|
||||||
ranges: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const slashCommandInitialState: SlashCommandState = {
|
const slashCommandInitialState: Record<string, SlashCommandState> = {};
|
||||||
isSlashCommand: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkPopoverState: LinkPopoverState = {};
|
const linkPopoverState: Record<string, LinkPopoverState> = {};
|
||||||
|
|
||||||
export const documentSlice = createSlice({
|
export const documentSlice = createSlice({
|
||||||
name: 'document',
|
name: 'document',
|
||||||
@ -39,21 +29,35 @@ export const documentSlice = createSlice({
|
|||||||
// Because the document state is updated by the `onDataChange`
|
// Because the document state is updated by the `onDataChange`
|
||||||
reducers: {
|
reducers: {
|
||||||
// initialize the document
|
// initialize the document
|
||||||
clear: () => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
return initialState;
|
const docId = action.payload;
|
||||||
|
|
||||||
|
state[docId] = {
|
||||||
|
nodes: {},
|
||||||
|
children: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clear: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
delete state[docId];
|
||||||
},
|
},
|
||||||
|
|
||||||
// set document data
|
// set document data
|
||||||
create: (
|
create: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
nodes: Record<string, Node>;
|
nodes: Record<string, Node>;
|
||||||
children: Record<string, string[]>;
|
children: Record<string, string[]>;
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { nodes, children } = action.payload;
|
const { docId, nodes, children } = action.payload;
|
||||||
state.nodes = nodes;
|
|
||||||
state.children = children;
|
state[docId] = {
|
||||||
|
nodes,
|
||||||
|
children,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
This function listens for changes in the data layer triggered by the data API,
|
This function listens for changes in the data layer triggered by the data API,
|
||||||
@ -65,17 +69,23 @@ export const documentSlice = createSlice({
|
|||||||
onDataChange: (
|
onDataChange: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
data: BlockEventPayloadPB;
|
data: BlockEventPayloadPB;
|
||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { path, id, value, command } = action.payload.data;
|
const { docId, data } = action.payload;
|
||||||
|
const { path, id, value, command } = data;
|
||||||
|
|
||||||
|
const documentState = state[docId];
|
||||||
|
|
||||||
|
if (!documentState) return;
|
||||||
const valueJson = parseValue(value);
|
const valueJson = parseValue(value);
|
||||||
|
|
||||||
if (!valueJson) return;
|
if (!valueJson) return;
|
||||||
|
|
||||||
// match change
|
// match change
|
||||||
matchChange(state, { path, id, value: valueJson, command });
|
matchChange(documentState, { path, id, value: valueJson, command });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -84,20 +94,57 @@ export const rectSelectionSlice = createSlice({
|
|||||||
name: 'documentRectSelection',
|
name: 'documentRectSelection',
|
||||||
initialState: rectSelectionInitialState,
|
initialState: rectSelectionInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
state[docId] = {
|
||||||
|
selection: [],
|
||||||
|
isDragging: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clear: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
delete state[docId];
|
||||||
|
},
|
||||||
// update block selections
|
// update block selections
|
||||||
updateSelections: (state, action: PayloadAction<string[]>) => {
|
updateSelections: (
|
||||||
state.selection = action.payload;
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
selection: string[];
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, selection } = action.payload;
|
||||||
|
|
||||||
|
state[docId].selection = selection;
|
||||||
},
|
},
|
||||||
|
|
||||||
// set block selected
|
// set block selected
|
||||||
setSelectionById: (state, action: PayloadAction<string>) => {
|
setSelectionById: (
|
||||||
const id = action.payload;
|
state,
|
||||||
if (state.selection.includes(id)) return;
|
action: PayloadAction<{
|
||||||
state.selection = [...state.selection, id];
|
docId: string;
|
||||||
|
blockId: string;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, blockId } = action.payload;
|
||||||
|
const selection = state[docId].selection;
|
||||||
|
|
||||||
|
if (selection.includes(blockId)) return;
|
||||||
|
state[docId].selection = [...selection, blockId];
|
||||||
},
|
},
|
||||||
|
|
||||||
setDragging: (state, action: PayloadAction<boolean>) => {
|
setDragging: (
|
||||||
state.isDragging = action.payload;
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
isDragging: boolean;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, isDragging } = action.payload;
|
||||||
|
|
||||||
|
state[docId].isDragging = isDragging;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -106,12 +153,34 @@ export const rangeSlice = createSlice({
|
|||||||
name: 'documentRange',
|
name: 'documentRange',
|
||||||
initialState: rangeInitialState,
|
initialState: rangeInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setRanges: (state, action: PayloadAction<RangeState['ranges']>) => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
state.ranges = action.payload;
|
const docId = action.payload;
|
||||||
|
|
||||||
|
state[docId] = {
|
||||||
|
isDragging: false,
|
||||||
|
ranges: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clear: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
delete state[docId];
|
||||||
|
},
|
||||||
|
setRanges: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
ranges: RangeState['ranges'];
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, ranges } = action.payload;
|
||||||
|
|
||||||
|
state[docId].ranges = ranges;
|
||||||
},
|
},
|
||||||
setRange: (
|
setRange: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
id: string;
|
id: string;
|
||||||
rangeStatic: {
|
rangeStatic: {
|
||||||
index: number;
|
index: number;
|
||||||
@ -119,84 +188,178 @@ export const rangeSlice = createSlice({
|
|||||||
};
|
};
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { id, rangeStatic } = action.payload;
|
const { docId, id, rangeStatic } = action.payload;
|
||||||
state.ranges[id] = rangeStatic;
|
|
||||||
|
state[docId].ranges[id] = rangeStatic;
|
||||||
},
|
},
|
||||||
removeRange: (state, action: PayloadAction<string>) => {
|
removeRange: (
|
||||||
const id = action.payload;
|
state,
|
||||||
delete state.ranges[id];
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
id: string;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, id } = action.payload;
|
||||||
|
const ranges = state[docId].ranges;
|
||||||
|
|
||||||
|
delete ranges[id];
|
||||||
},
|
},
|
||||||
setAnchorPoint: (
|
setAnchorPoint: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
id: string;
|
id: string;
|
||||||
point: { x: number; y: number };
|
point: { x: number; y: number };
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
state.anchor = action.payload;
|
const { docId, id, point } = action.payload;
|
||||||
|
|
||||||
|
state[docId].anchor = {
|
||||||
|
id,
|
||||||
|
point,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
setAnchorPointRange: (
|
setAnchorPointRange: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
index: number;
|
index: number;
|
||||||
length: number;
|
length: number;
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const anchor = state.anchor;
|
const { docId, index, length } = action.payload;
|
||||||
|
const anchor = state[docId].anchor;
|
||||||
|
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
anchor.point = {
|
anchor.point = {
|
||||||
...anchor.point,
|
...anchor.point,
|
||||||
...action.payload,
|
index,
|
||||||
|
length,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setFocusPoint: (
|
setFocusPoint: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
id: string;
|
id: string;
|
||||||
point: { x: number; y: number };
|
point: { x: number; y: number };
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
state.focus = action.payload;
|
const { docId, id, point } = action.payload;
|
||||||
|
|
||||||
|
state[docId].focus = {
|
||||||
|
id,
|
||||||
|
point,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
setDragging: (state, action: PayloadAction<boolean>) => {
|
setDragging: (
|
||||||
state.isDragging = action.payload;
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
isDragging: boolean;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, isDragging } = action.payload;
|
||||||
|
|
||||||
|
state[docId].isDragging = isDragging;
|
||||||
},
|
},
|
||||||
setCaret: (state, action: PayloadAction<RangeStatic | null>) => {
|
setCaret: (
|
||||||
if (!action.payload) {
|
state,
|
||||||
state.caret = undefined;
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
caret: RangeStatic | null;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, caret } = action.payload;
|
||||||
|
const rangeState = state[docId];
|
||||||
|
|
||||||
|
if (!caret) {
|
||||||
|
rangeState.caret = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = action.payload.id;
|
|
||||||
state.ranges[id] = {
|
const { id, index, length } = caret;
|
||||||
index: action.payload.index,
|
|
||||||
length: action.payload.length,
|
rangeState.ranges[id] = {
|
||||||
|
index,
|
||||||
|
length,
|
||||||
};
|
};
|
||||||
state.caret = action.payload;
|
rangeState.caret = caret;
|
||||||
},
|
},
|
||||||
clearRange: (state, _: PayloadAction) => {
|
clearRanges: (
|
||||||
return rangeInitialState;
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
exclude?: string;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, exclude } = action.payload;
|
||||||
|
const ranges = state[docId].ranges;
|
||||||
|
const newRanges = Object.keys(ranges).reduce((acc, id) => {
|
||||||
|
if (id !== exclude) return { ...acc };
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[id]: ranges[id],
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
state[docId].ranges = newRanges;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const slashCommandSlice = createSlice({
|
export const slashCommandSlice = createSlice({
|
||||||
name: 'documentSlashCommand',
|
name: 'documentSlashCommand',
|
||||||
initialState: slashCommandInitialState,
|
initialState: slashCommandInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
state[docId] = {
|
||||||
|
isSlashCommand: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clear: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
delete state[docId];
|
||||||
|
},
|
||||||
openSlashCommand: (
|
openSlashCommand: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
blockId: string;
|
blockId: string;
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { blockId } = action.payload;
|
const { blockId, docId } = action.payload;
|
||||||
return {
|
|
||||||
...state,
|
state[docId] = {
|
||||||
|
...state[docId],
|
||||||
isSlashCommand: true,
|
isSlashCommand: true,
|
||||||
blockId,
|
blockId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
closeSlashCommand: (state, _: PayloadAction) => {
|
closeSlashCommand: (state, action: PayloadAction<string>) => {
|
||||||
return slashCommandInitialState;
|
const docId = action.payload;
|
||||||
|
|
||||||
|
state[docId] = {
|
||||||
|
...state[docId],
|
||||||
|
isSlashCommand: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setHoverOption: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
option: SlashCommandOption;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, option } = action.payload;
|
||||||
|
|
||||||
|
state[docId] = {
|
||||||
|
...state[docId],
|
||||||
|
hoverOption: option,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -205,28 +368,46 @@ export const linkPopoverSlice = createSlice({
|
|||||||
name: 'documentLinkPopover',
|
name: 'documentLinkPopover',
|
||||||
initialState: linkPopoverState,
|
initialState: linkPopoverState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
return {
|
const docId = action.payload;
|
||||||
...state,
|
|
||||||
...action.payload,
|
state[docId] = {
|
||||||
};
|
|
||||||
},
|
|
||||||
updateLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
|
|
||||||
const { id } = action.payload;
|
|
||||||
if (!state.open || state.id !== id) return;
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
...action.payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
closeLinkPopover: (state, _: PayloadAction) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
open: false,
|
open: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
resetLinkPopover: (state, _: PayloadAction) => {
|
clear: (state, action: PayloadAction<string>) => {
|
||||||
return linkPopoverState;
|
const docId = action.payload;
|
||||||
|
|
||||||
|
delete state[docId];
|
||||||
|
},
|
||||||
|
setLinkPopover: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
linkState: LinkPopoverState;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, linkState } = action.payload;
|
||||||
|
|
||||||
|
state[docId] = linkState;
|
||||||
|
},
|
||||||
|
updateLinkPopover: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
docId: string;
|
||||||
|
linkState: LinkPopoverState;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { docId, linkState } = action.payload;
|
||||||
|
const { id } = linkState;
|
||||||
|
|
||||||
|
if (!state[docId].open || state[docId].id !== id) return;
|
||||||
|
state[docId] = linkState;
|
||||||
|
},
|
||||||
|
closeLinkPopover: (state, action: PayloadAction<string>) => {
|
||||||
|
const docId = action.payload;
|
||||||
|
|
||||||
|
state[docId].open = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -27,40 +27,50 @@ import {
|
|||||||
export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
|
export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
|
||||||
const middleIds = [];
|
const middleIds = [];
|
||||||
let currentId: string | undefined = startId;
|
let currentId: string | undefined = startId;
|
||||||
|
|
||||||
while (currentId && currentId !== endId) {
|
while (currentId && currentId !== endId) {
|
||||||
const nextId = getNextLineId(document, currentId);
|
const nextId = getNextLineId(document, currentId);
|
||||||
|
|
||||||
if (nextId && nextId !== endId) {
|
if (nextId && nextId !== endId) {
|
||||||
middleIds.push(nextId);
|
middleIds.push(nextId);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentId = nextId;
|
currentId = nextId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return middleIds;
|
return middleIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStartAndEndIdsByRange(rangeState: RangeState) {
|
export function getStartAndEndIdsByRange(rangeState: RangeState) {
|
||||||
const { anchor, focus } = rangeState;
|
const { anchor, focus } = rangeState;
|
||||||
|
|
||||||
if (!anchor || !focus) return [];
|
if (!anchor || !focus) return [];
|
||||||
if (anchor.id === focus.id) return [anchor.id];
|
if (anchor.id === focus.id) return [anchor.id];
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const startId = isForward ? anchor.id : focus.id;
|
const startId = isForward ? anchor.id : focus.id;
|
||||||
const endId = isForward ? focus.id : anchor.id;
|
const endId = isForward ? focus.id : anchor.id;
|
||||||
|
|
||||||
return [startId, endId];
|
return [startId, endId];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
|
export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
|
||||||
const ids = getStartAndEndIdsByRange(rangeState);
|
const ids = getStartAndEndIdsByRange(rangeState);
|
||||||
|
|
||||||
if (ids.length < 2) return;
|
if (ids.length < 2) return;
|
||||||
const [startId, endId] = ids;
|
const [startId, endId] = ids;
|
||||||
|
|
||||||
return getMiddleIds(document, startId, endId);
|
return getMiddleIds(document, startId, endId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
|
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
|
||||||
const { anchor, focus, ranges } = rangeState;
|
const { anchor, focus, ranges } = rangeState;
|
||||||
|
|
||||||
if (!anchor || !focus) return;
|
if (!anchor || !focus) return;
|
||||||
|
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const startId = isForward ? anchor.id : focus.id;
|
const startId = isForward ? anchor.id : focus.id;
|
||||||
const startRange = ranges[startId];
|
const startRange = ranges[startId];
|
||||||
|
|
||||||
if (!startRange) return;
|
if (!startRange) return;
|
||||||
const offset = insertDelta ? insertDelta.length() : 0;
|
const offset = insertDelta ? insertDelta.length() : 0;
|
||||||
|
|
||||||
@ -71,9 +81,9 @@ export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStartAndEndExtentDelta(state: RootState) {
|
export function getStartAndEndExtentDelta(documentState: DocumentState, rangeState: RangeState) {
|
||||||
const rangeState = state.documentRange;
|
|
||||||
const ids = getStartAndEndIdsByRange(rangeState);
|
const ids = getStartAndEndIdsByRange(rangeState);
|
||||||
|
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const startId = ids[0];
|
const startId = ids[0];
|
||||||
const endId = ids[ids.length - 1];
|
const endId = ids[ids.length - 1];
|
||||||
@ -81,12 +91,13 @@ export function getStartAndEndExtentDelta(state: RootState) {
|
|||||||
// get start and end delta
|
// get start and end delta
|
||||||
const startRange = ranges[startId];
|
const startRange = ranges[startId];
|
||||||
const endRange = ranges[endId];
|
const endRange = ranges[endId];
|
||||||
|
|
||||||
if (!startRange || !endRange) return;
|
if (!startRange || !endRange) return;
|
||||||
const startNode = state.document.nodes[startId];
|
const startNode = documentState.nodes[startId];
|
||||||
const startNodeDelta = new Delta(startNode.data.delta);
|
const startNodeDelta = new Delta(startNode.data.delta);
|
||||||
const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
|
const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
|
||||||
|
|
||||||
const endNode = state.document.nodes[endId];
|
const endNode = documentState.nodes[endId];
|
||||||
const endNodeDelta = new Delta(endNode.data.delta);
|
const endNodeDelta = new Delta(endNode.data.delta);
|
||||||
const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
|
const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
|
||||||
|
|
||||||
@ -104,10 +115,15 @@ export function getMergeEndDeltaToStartActionsByRange(
|
|||||||
insertDelta?: Delta
|
insertDelta?: Delta
|
||||||
) {
|
) {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {};
|
const docId = controller.documentId;
|
||||||
|
const documentState = state.document[docId];
|
||||||
|
const rangeState = state.documentRange[docId];
|
||||||
|
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
||||||
|
|
||||||
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
||||||
// merge start and end nodes
|
// merge start and end nodes
|
||||||
const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
|
const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
|
||||||
|
|
||||||
actions.push(
|
actions.push(
|
||||||
controller.getUpdateAction({
|
controller.getUpdateAction({
|
||||||
...startNode,
|
...startNode,
|
||||||
@ -117,13 +133,14 @@ export function getMergeEndDeltaToStartActionsByRange(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (endNode.id !== startNode.id) {
|
if (endNode.id !== startNode.id) {
|
||||||
const children = state.document.children[endNode.children].map((id) => state.document.nodes[id]);
|
const children = documentState.children[endNode.children].map((id) => documentState.nodes[id]);
|
||||||
|
|
||||||
const moveChildrenActions = getMoveChildrenActions({
|
const moveChildrenActions = getMoveChildrenActions({
|
||||||
target: startNode,
|
target: startNode,
|
||||||
children,
|
children,
|
||||||
controller,
|
controller,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
// delete end node
|
// delete end node
|
||||||
actions.push(controller.getDeleteAction(endNode));
|
actions.push(controller.getDeleteAction(endNode));
|
||||||
@ -146,9 +163,11 @@ export function getMoveChildrenActions({
|
|||||||
// move children
|
// move children
|
||||||
const config = blockConfig[target.type];
|
const config = blockConfig[target.type];
|
||||||
const targetParentId = config.canAddChild ? target.id : target.parent;
|
const targetParentId = config.canAddChild ? target.id : target.parent;
|
||||||
|
|
||||||
if (!targetParentId) return [];
|
if (!targetParentId) return [];
|
||||||
const targetPrevId = targetParentId === target.id ? prevId : target.id;
|
const targetPrevId = targetParentId === target.id ? prevId : target.id;
|
||||||
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
|
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
|
||||||
|
|
||||||
return moveActions;
|
return moveActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,10 +183,12 @@ export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
|
|||||||
const newNodeType = config.nextLineBlockType;
|
const newNodeType = config.nextLineBlockType;
|
||||||
const relationShip = config.nextLineRelationShip;
|
const relationShip = config.nextLineRelationShip;
|
||||||
const defaultData = blockConfig[newNodeType].defaultData;
|
const defaultData = blockConfig[newNodeType].defaultData;
|
||||||
|
|
||||||
// if the defaultData property is not defined for the new block type, we throw an error.
|
// if the defaultData property is not defined for the new block type, we throw an error.
|
||||||
if (!defaultData) {
|
if (!defaultData) {
|
||||||
throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
|
throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
|
const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
|
||||||
const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
|
const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
|
||||||
|
|
||||||
@ -185,6 +206,7 @@ export function getInsertEnterNodeAction(
|
|||||||
controller: DocumentController
|
controller: DocumentController
|
||||||
) {
|
) {
|
||||||
const insertNodeFields = getInsertEnterNodeFields(sourceNode);
|
const insertNodeFields = getInsertEnterNodeFields(sourceNode);
|
||||||
|
|
||||||
if (!insertNodeFields) return;
|
if (!insertNodeFields) return;
|
||||||
const { type, data, parentId, prevId } = insertNodeFields;
|
const { type, data, parentId, prevId } = insertNodeFields;
|
||||||
const insertNode = newBlock<any>(type, parentId, {
|
const insertNode = newBlock<any>(type, parentId, {
|
||||||
@ -200,27 +222,35 @@ export function getInsertEnterNodeAction(
|
|||||||
|
|
||||||
export function findPrevHasDeltaNode(state: DocumentState, id: string) {
|
export function findPrevHasDeltaNode(state: DocumentState, id: string) {
|
||||||
const prevLineId = getPrevLineId(state, id);
|
const prevLineId = getPrevLineId(state, id);
|
||||||
|
|
||||||
if (!prevLineId) return;
|
if (!prevLineId) return;
|
||||||
let prevLine = state.nodes[prevLineId];
|
let prevLine = state.nodes[prevLineId];
|
||||||
|
|
||||||
// Find the prev line that has delta
|
// Find the prev line that has delta
|
||||||
while (prevLine && !prevLine.data.delta) {
|
while (prevLine && !prevLine.data.delta) {
|
||||||
const id = getPrevLineId(state, prevLine.id);
|
const id = getPrevLineId(state, prevLine.id);
|
||||||
|
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
prevLine = state.nodes[id];
|
prevLine = state.nodes[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
return prevLine;
|
return prevLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findNextHasDeltaNode(state: DocumentState, id: string) {
|
export function findNextHasDeltaNode(state: DocumentState, id: string) {
|
||||||
const nextLineId = getNextLineId(state, id);
|
const nextLineId = getNextLineId(state, id);
|
||||||
|
|
||||||
if (!nextLineId) return;
|
if (!nextLineId) return;
|
||||||
let nextLine = state.nodes[nextLineId];
|
let nextLine = state.nodes[nextLineId];
|
||||||
|
|
||||||
// Find the next line that has delta
|
// Find the next line that has delta
|
||||||
while (nextLine && !nextLine.data.delta) {
|
while (nextLine && !nextLine.data.delta) {
|
||||||
const id = getNextLineId(state, nextLine.id);
|
const id = getNextLineId(state, nextLine.id);
|
||||||
|
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
nextLine = state.nodes[id];
|
nextLine = state.nodes[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextLine;
|
return nextLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,11 +263,13 @@ export function isPrintableKeyEvent(event: KeyboardEvent) {
|
|||||||
|
|
||||||
export function getLeftCaretByRange(rangeState: RangeState) {
|
export function getLeftCaretByRange(rangeState: RangeState) {
|
||||||
const { anchor, ranges, focus } = rangeState;
|
const { anchor, ranges, focus } = rangeState;
|
||||||
|
|
||||||
if (!anchor || !focus) return;
|
if (!anchor || !focus) return;
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const startId = isForward ? anchor.id : focus.id;
|
const startId = isForward ? anchor.id : focus.id;
|
||||||
|
|
||||||
const range = ranges[startId];
|
const range = ranges[startId];
|
||||||
|
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
return {
|
return {
|
||||||
id: startId,
|
id: startId,
|
||||||
@ -248,11 +280,13 @@ export function getLeftCaretByRange(rangeState: RangeState) {
|
|||||||
|
|
||||||
export function getRightCaretByRange(rangeState: RangeState) {
|
export function getRightCaretByRange(rangeState: RangeState) {
|
||||||
const { anchor, focus, ranges, caret } = rangeState;
|
const { anchor, focus, ranges, caret } = rangeState;
|
||||||
|
|
||||||
if (!anchor || !focus) return;
|
if (!anchor || !focus) return;
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const endId = isForward ? focus.id : anchor.id;
|
const endId = isForward ? focus.id : anchor.id;
|
||||||
|
|
||||||
const range = ranges[endId];
|
const range = ranges[endId];
|
||||||
|
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -268,13 +302,16 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
|
|||||||
|
|
||||||
if (!inTopEdge) {
|
if (!inTopEdge) {
|
||||||
const index = transformIndexToPrevLine(delta, caret.index);
|
const index = transformIndexToPrevLine(delta, caret.index);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: caret.id,
|
id: caret.id,
|
||||||
index,
|
index,
|
||||||
length: 0,
|
length: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevLine = findPrevHasDeltaNode(document, caret.id);
|
const prevLine = findPrevHasDeltaNode(document, caret.id);
|
||||||
|
|
||||||
if (!prevLine) return;
|
if (!prevLine) return;
|
||||||
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
|
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
|
||||||
const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
|
const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
|
||||||
@ -282,6 +319,7 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
|
|||||||
const newPrevLineIndex = prevLineIndex + relativeIndex;
|
const newPrevLineIndex = prevLineIndex + relativeIndex;
|
||||||
const prevLineLength = prevLineText.length;
|
const prevLineLength = prevLineText.length;
|
||||||
const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
|
const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: prevLine.id,
|
id: prevLine.id,
|
||||||
index,
|
index,
|
||||||
@ -292,8 +330,10 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
|
|||||||
export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
|
export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
|
||||||
const delta = new Delta(document.nodes[caret.id].data.delta);
|
const delta = new Delta(document.nodes[caret.id].data.delta);
|
||||||
const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
|
const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
|
||||||
|
|
||||||
if (!inBottomEdge) {
|
if (!inBottomEdge) {
|
||||||
const index = transformIndexToNextLine(delta, caret.index);
|
const index = transformIndexToNextLine(delta, caret.index);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: caret.id,
|
id: caret.id,
|
||||||
index,
|
index,
|
||||||
@ -303,6 +343,7 @@ export function transformToNextLineCaret(document: DocumentState, caret: RangeSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextLine = findNextHasDeltaNode(document, caret.id);
|
const nextLine = findNextHasDeltaNode(document, caret.id);
|
||||||
|
|
||||||
if (!nextLine) return;
|
if (!nextLine) return;
|
||||||
const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
|
const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
|
||||||
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
|
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
|
||||||
@ -323,15 +364,19 @@ export function getDuplicateActions(
|
|||||||
) {
|
) {
|
||||||
const actions: ControllerAction[] = [];
|
const actions: ControllerAction[] = [];
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
// duplicate new node
|
// duplicate new node
|
||||||
const newNode = newBlock<any>(node.type, parentId, {
|
const newNode = newBlock<any>(node.type, parentId, {
|
||||||
...node.data,
|
...node.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(controller.getInsertAction(newNode, node.id));
|
actions.push(controller.getInsertAction(newNode, node.id));
|
||||||
const children = document.children[node.children];
|
const children = document.children[node.children];
|
||||||
|
|
||||||
children.forEach((child) => {
|
children.forEach((child) => {
|
||||||
const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller);
|
const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller);
|
||||||
|
|
||||||
if (!duplicateChildActions) return;
|
if (!duplicateChildActions) return;
|
||||||
actions.push(...duplicateChildActions.actions);
|
actions.push(...duplicateChildActions.actions);
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,6 @@ import { getDeltaByRange } from '$app/utils/document/delta';
|
|||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { generateId } from '$app/utils/document/block';
|
import { generateId } from '$app/utils/document/block';
|
||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
|
||||||
|
|
||||||
export function getCopyData(
|
export function getCopyData(
|
||||||
node: NestedBlock,
|
node: NestedBlock,
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
export function selectOptionByUpDown(isUp: boolean, selected: string | null, options: string[]) {
|
||||||
|
const index = options.findIndex((option) => option === selected);
|
||||||
|
const length = options.length;
|
||||||
|
|
||||||
|
const nextIndex = isUp ? (index - 1 + length) % length : (index + 1) % length;
|
||||||
|
|
||||||
|
return options[nextIndex];
|
||||||
|
}
|
@ -194,7 +194,11 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
|
|||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
return true;
|
const focusNode = selection?.focusNode;
|
||||||
|
if (!focusNode) return false;
|
||||||
|
|
||||||
|
const parent = findParent(focusNode as Element, node);
|
||||||
|
return Boolean(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeTextBoxByBlockId(blockId: string) {
|
export function getNodeTextBoxByBlockId(blockId: string) {
|
||||||
@ -225,41 +229,16 @@ export function replaceZeroWidthSpace(text: string) {
|
|||||||
return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findParent(node: Element, parentSelector: string) {
|
export function findParent(node: Element, parentSelector: string | Element) {
|
||||||
let parentNode: Element | null = node;
|
let parentNode: Element | null = node;
|
||||||
while (parentNode) {
|
while (parentNode) {
|
||||||
if (parentNode.matches(parentSelector)) {
|
if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
|
||||||
|
return parentNode;
|
||||||
|
}
|
||||||
|
if (parentNode === parentSelector) {
|
||||||
return parentNode;
|
return parentNode;
|
||||||
}
|
}
|
||||||
parentNode = parentNode.parentElement;
|
parentNode = parentNode.parentElement;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWordIndices(startContainer: Node, startOffset: number) {
|
|
||||||
const textNode = startContainer;
|
|
||||||
const textContent = textNode.textContent || '';
|
|
||||||
|
|
||||||
const wordRegex = /\b\w+\b/g;
|
|
||||||
let match;
|
|
||||||
const wordIndices = [];
|
|
||||||
|
|
||||||
while ((match = wordRegex.exec(textContent)) !== null) {
|
|
||||||
const word = match[0];
|
|
||||||
const wordIndex = match.index;
|
|
||||||
const wordEndIndex = wordIndex + word.length;
|
|
||||||
|
|
||||||
// If the startOffset is greater than the wordIndex and less than the wordEndIndex, then the startOffset is
|
|
||||||
if (startOffset > wordIndex && startOffset <= wordEndIndex) {
|
|
||||||
wordIndices.push({
|
|
||||||
word: word,
|
|
||||||
startIndex: wordIndex,
|
|
||||||
endIndex: wordEndIndex,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are no matches, then the startOffset is greater than the last wordEndIndex
|
|
||||||
return wordIndices;
|
|
||||||
}
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { DeltaTypePB } from "@/services/backend/models/flowy-document2";
|
import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
|
||||||
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from "$app/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 "$app/constants/document/block";
|
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
|
||||||
import { isEqual } from "$app/utils/tool";
|
import { isEqual } from '$app/utils/tool';
|
||||||
|
|
||||||
// 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 = [
|
||||||
@ -26,7 +26,7 @@ export function matchChange(
|
|||||||
path: string[];
|
path: string[];
|
||||||
id: string;
|
id: string;
|
||||||
value: BlockPBValue & string[];
|
value: BlockPBValue & string[];
|
||||||
},
|
}
|
||||||
) {
|
) {
|
||||||
const matchCase = matchCases.find((item) => item.match(command, path));
|
const matchCase = matchCases.find((item) => item.match(command, path));
|
||||||
|
|
||||||
@ -106,7 +106,9 @@ function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: B
|
|||||||
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
|
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
|
||||||
const block = blockChangeValue2Node(blockValue);
|
const block = blockChangeValue2Node(blockValue);
|
||||||
const node = state.nodes[blockId];
|
const node = state.nodes[blockId];
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
if (isEqual(node, block)) return;
|
if (isEqual(node, block)) return;
|
||||||
state.nodes[blockId] = block;
|
state.nodes[blockId] = block;
|
||||||
return;
|
return;
|
||||||
@ -122,6 +124,7 @@ function onMatchChildrenInsert(state: DocumentState, id: string, children: strin
|
|||||||
|
|
||||||
function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) {
|
function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) {
|
||||||
const children = state.children[id];
|
const children = state.children[id];
|
||||||
|
|
||||||
if (!children) return;
|
if (!children) return;
|
||||||
state.children[id] = newChildren;
|
state.children[id] = newChildren;
|
||||||
}
|
}
|
||||||
@ -144,6 +147,7 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
|
|||||||
delta: [],
|
delta: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if ('data' in value && typeof value.data === 'string') {
|
if ('data' in value && typeof value.data === 'string') {
|
||||||
try {
|
try {
|
||||||
Object.assign(block, {
|
Object.assign(block, {
|
||||||
@ -159,11 +163,13 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
|
|||||||
|
|
||||||
export function parseValue(value: string) {
|
export function parseValue(value: string) {
|
||||||
let valueJson;
|
let valueJson;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
valueJson = JSON.parse(value);
|
valueJson = JSON.parse(value);
|
||||||
} catch {
|
} catch {
|
||||||
Log.error('[onDataChange] json parse error', value);
|
Log.error('[onDataChange] json parse error', value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return valueJson;
|
return valueJson;
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,13 @@ import { DocumentData } from '../interfaces/document';
|
|||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { useAppDispatch } from '../stores/store';
|
import { useAppDispatch } from '../stores/store';
|
||||||
import { Log } from '../utils/log';
|
import { Log } from '../utils/log';
|
||||||
import { documentActions } from '../stores/reducers/document/slice';
|
import {
|
||||||
|
documentActions,
|
||||||
|
linkPopoverActions,
|
||||||
|
rangeActions,
|
||||||
|
rectSelectionActions,
|
||||||
|
slashCommandActions,
|
||||||
|
} from '$app/stores/reducers/document/slice';
|
||||||
import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2';
|
import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2';
|
||||||
|
|
||||||
export const useDocument = () => {
|
export const useDocument = () => {
|
||||||
@ -14,20 +20,60 @@ export const useDocument = () => {
|
|||||||
const [controller, setController] = useState<DocumentController | null>(null);
|
const [controller, setController] = useState<DocumentController | null>(null);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const onDocumentChange = useCallback((props: { isRemote: boolean; data: BlockEventPayloadPB }) => {
|
const onDocumentChange = useCallback(
|
||||||
dispatch(documentActions.onDataChange(props));
|
(props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => {
|
||||||
}, []);
|
dispatch(documentActions.onDataChange(props));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeDocument = useCallback(
|
||||||
|
(docId: string) => {
|
||||||
|
Log.debug('initialize document', docId);
|
||||||
|
dispatch(documentActions.initialState(docId));
|
||||||
|
dispatch(rangeActions.initialState(docId));
|
||||||
|
dispatch(rectSelectionActions.initialState(docId));
|
||||||
|
dispatch(slashCommandActions.initialState(docId));
|
||||||
|
dispatch(linkPopoverActions.initialState(docId));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDocument = useCallback(
|
||||||
|
(docId: string) => {
|
||||||
|
Log.debug('clear document', docId);
|
||||||
|
dispatch(documentActions.clear(docId));
|
||||||
|
dispatch(rangeActions.clear(docId));
|
||||||
|
dispatch(rectSelectionActions.clear(docId));
|
||||||
|
dispatch(slashCommandActions.clear(docId));
|
||||||
|
dispatch(linkPopoverActions.clear(docId));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let documentController: DocumentController | null = null;
|
let documentController: DocumentController | null = null;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (!params?.id) return;
|
if (!params?.id) return;
|
||||||
Log.debug('open document', params.id);
|
|
||||||
documentController = new DocumentController(params.id, onDocumentChange);
|
documentController = new DocumentController(params.id, onDocumentChange);
|
||||||
|
const docId = documentController.documentId;
|
||||||
|
|
||||||
|
Log.debug('open document', params.id);
|
||||||
|
|
||||||
|
initializeDocument(documentController.documentId);
|
||||||
|
|
||||||
setController(documentController);
|
setController(documentController);
|
||||||
try {
|
try {
|
||||||
const res = await documentController.open();
|
const res = await documentController.open();
|
||||||
|
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
dispatch(
|
||||||
|
documentActions.create({
|
||||||
|
...res,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
setDocumentData(res);
|
setDocumentData(res);
|
||||||
setDocumentId(params.id);
|
setDocumentId(params.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -35,15 +81,17 @@ export const useDocument = () => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const closeDocument = () => {
|
return () => {
|
||||||
if (documentController) {
|
if (documentController) {
|
||||||
void documentController.dispose();
|
void (async () => {
|
||||||
|
await documentController.dispose();
|
||||||
|
clearDocument(documentController.documentId);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.debug('close document', params.id);
|
Log.debug('close document', params.id);
|
||||||
};
|
};
|
||||||
|
}, [clearDocument, dispatch, initializeDocument, onDocumentChange, params.id]);
|
||||||
return closeDocument;
|
|
||||||
}, [params.id]);
|
|
||||||
|
|
||||||
return { documentId, documentData, controller };
|
return { documentId, documentData, controller };
|
||||||
};
|
};
|
||||||
|
@ -6,7 +6,7 @@ import { DocumentControllerContext } from '../stores/effects/document/document_c
|
|||||||
const muiTheme = createTheme({
|
const muiTheme = createTheme({
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: ['Poppins'].join(','),
|
fontFamily: ['Poppins'].join(','),
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
|
@ -105,7 +105,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
|
|||||||
let json_str = include_str!("../../assets/read_me.json");
|
let json_str = include_str!("../../assets/read_me.json");
|
||||||
let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap();
|
let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap();
|
||||||
manager
|
manager
|
||||||
.create_document(view.parent_view.id.clone(), Some(document_pb.into()))
|
.create_document(&view.parent_view.id, Some(document_pb.into()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
view
|
view
|
||||||
})
|
})
|
||||||
@ -143,7 +143,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
|
|||||||
let manager = self.0.clone();
|
let manager = self.0.clone();
|
||||||
let view_id = view_id.to_string();
|
let view_id = view_id.to_string();
|
||||||
FutureResult::new(async move {
|
FutureResult::new(async move {
|
||||||
let document = manager.get_document_from_disk(view_id)?;
|
let document = manager.get_document_from_disk(&view_id)?;
|
||||||
let data: DocumentDataPB = document.lock().get_document()?.into();
|
let data: DocumentDataPB = document.lock().get_document()?.into();
|
||||||
let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?;
|
let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?;
|
||||||
Ok(data_bytes)
|
Ok(data_bytes)
|
||||||
@ -164,7 +164,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
|
|||||||
let manager = self.0.clone();
|
let manager = self.0.clone();
|
||||||
FutureResult::new(async move {
|
FutureResult::new(async move {
|
||||||
let data = DocumentDataPB::try_from(Bytes::from(data))?;
|
let data = DocumentDataPB::try_from(Bytes::from(data))?;
|
||||||
manager.create_document(view_id, Some(data.into()))?;
|
manager.create_document(&view_id, Some(data.into()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -181,7 +181,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
|
|||||||
let view_id = view_id.to_string();
|
let view_id = view_id.to_string();
|
||||||
let manager = self.0.clone();
|
let manager = self.0.clone();
|
||||||
FutureResult::new(async move {
|
FutureResult::new(async move {
|
||||||
manager.create_document(view_id, None)?;
|
manager.create_document(&view_id, None)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -197,7 +197,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
|
|||||||
let manager = self.0.clone();
|
let manager = self.0.clone();
|
||||||
FutureResult::new(async move {
|
FutureResult::new(async move {
|
||||||
let data = DocumentDataPB::try_from(Bytes::from(bytes))?;
|
let data = DocumentDataPB::try_from(Bytes::from(bytes))?;
|
||||||
manager.create_document(view_id, Some(data.into()))?;
|
manager.create_document(&view_id, Some(data.into()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ pub(crate) async fn create_document_handler(
|
|||||||
manager: AFPluginState<Arc<DocumentManager>>,
|
manager: AFPluginState<Arc<DocumentManager>>,
|
||||||
) -> FlowyResult<()> {
|
) -> FlowyResult<()> {
|
||||||
let params: CreateDocumentParams = data.into_inner().try_into()?;
|
let params: CreateDocumentParams = data.into_inner().try_into()?;
|
||||||
manager.create_document(params.document_id, params.initial_data)?;
|
manager.create_document(¶ms.document_id, params.initial_data)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ pub(crate) async fn open_document_handler(
|
|||||||
) -> DataResult<DocumentDataPB, FlowyError> {
|
) -> DataResult<DocumentDataPB, FlowyError> {
|
||||||
let params: OpenDocumentParams = data.into_inner().try_into()?;
|
let params: OpenDocumentParams = data.into_inner().try_into()?;
|
||||||
let doc_id = params.document_id;
|
let doc_id = params.document_id;
|
||||||
let document = manager.get_or_open_document(doc_id)?;
|
let document = manager.get_or_open_document(&doc_id)?;
|
||||||
let document_data = document.lock().get_document()?;
|
let document_data = document.lock().get_document()?;
|
||||||
data_result_ok(DocumentDataPB::from(document_data))
|
data_result_ok(DocumentDataPB::from(document_data))
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ pub(crate) async fn get_document_data_handler(
|
|||||||
) -> DataResult<DocumentDataPB, FlowyError> {
|
) -> DataResult<DocumentDataPB, FlowyError> {
|
||||||
let params: OpenDocumentParams = data.into_inner().try_into()?;
|
let params: OpenDocumentParams = data.into_inner().try_into()?;
|
||||||
let doc_id = params.document_id;
|
let doc_id = params.document_id;
|
||||||
let document = manager.get_document_from_disk(doc_id)?;
|
let document = manager.get_document_from_disk(&doc_id)?;
|
||||||
let document_data = document.lock().get_document()?;
|
let document_data = document.lock().get_document()?;
|
||||||
data_result_ok(DocumentDataPB::from(document_data))
|
data_result_ok(DocumentDataPB::from(document_data))
|
||||||
}
|
}
|
||||||
@ -81,7 +81,7 @@ pub(crate) async fn apply_action_handler(
|
|||||||
) -> FlowyResult<()> {
|
) -> FlowyResult<()> {
|
||||||
let params: ApplyActionParams = data.into_inner().try_into()?;
|
let params: ApplyActionParams = data.into_inner().try_into()?;
|
||||||
let doc_id = params.document_id;
|
let doc_id = params.document_id;
|
||||||
let document = manager.get_or_open_document(doc_id)?;
|
let document = manager.get_or_open_document(&doc_id)?;
|
||||||
let actions = params.actions;
|
let actions = params.actions;
|
||||||
document.lock().apply_action(actions);
|
document.lock().apply_action(actions);
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -117,7 +117,7 @@ pub(crate) async fn redo_handler(
|
|||||||
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
|
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
|
||||||
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
|
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
|
||||||
let doc_id = params.document_id;
|
let doc_id = params.document_id;
|
||||||
let document = manager.get_or_open_document(doc_id)?;
|
let document = manager.get_or_open_document(&doc_id)?;
|
||||||
let document = document.lock();
|
let document = document.lock();
|
||||||
let redo = document.redo();
|
let redo = document.redo();
|
||||||
let can_redo = document.can_redo();
|
let can_redo = document.can_redo();
|
||||||
@ -135,7 +135,7 @@ pub(crate) async fn undo_handler(
|
|||||||
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
|
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
|
||||||
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
|
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
|
||||||
let doc_id = params.document_id;
|
let doc_id = params.document_id;
|
||||||
let document = manager.get_or_open_document(doc_id)?;
|
let document = manager.get_or_open_document(&doc_id)?;
|
||||||
let document = document.lock();
|
let document = document.lock();
|
||||||
let undo = document.undo();
|
let undo = document.undo();
|
||||||
let can_redo = document.can_redo();
|
let can_redo = document.can_redo();
|
||||||
@ -153,7 +153,7 @@ pub(crate) async fn can_undo_redo_handler(
|
|||||||
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
|
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
|
||||||
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
|
let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
|
||||||
let doc_id = params.document_id;
|
let doc_id = params.document_id;
|
||||||
let document = manager.get_or_open_document(doc_id)?;
|
let document = manager.get_or_open_document(&doc_id)?;
|
||||||
let document = document.lock();
|
let document = document.lock();
|
||||||
let can_redo = document.can_redo();
|
let can_redo = document.can_redo();
|
||||||
let can_undo = document.can_undo();
|
let can_undo = document.can_undo();
|
||||||
|
@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc};
|
|||||||
use appflowy_integrate::collab_builder::AppFlowyCollabBuilder;
|
use appflowy_integrate::collab_builder::AppFlowyCollabBuilder;
|
||||||
use appflowy_integrate::RocksCollabDB;
|
use appflowy_integrate::RocksCollabDB;
|
||||||
use collab_document::blocks::DocumentData;
|
use collab_document::blocks::DocumentData;
|
||||||
|
use collab_document::error::DocumentError;
|
||||||
use collab_document::YrsDocAction;
|
use collab_document::YrsDocAction;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
@ -42,13 +43,13 @@ impl DocumentManager {
|
|||||||
/// if the data is None, will create a document with default data.
|
/// if the data is None, will create a document with default data.
|
||||||
pub fn create_document(
|
pub fn create_document(
|
||||||
&self,
|
&self,
|
||||||
doc_id: String,
|
doc_id: &str,
|
||||||
data: Option<DocumentData>,
|
data: Option<DocumentData>,
|
||||||
) -> FlowyResult<Arc<Document>> {
|
) -> FlowyResult<Arc<Document>> {
|
||||||
tracing::debug!("create a document: {:?}", &doc_id);
|
tracing::debug!("create a document: {:?}", doc_id);
|
||||||
let uid = self.user.user_id()?;
|
let uid = self.user.user_id()?;
|
||||||
let db = self.user.collab_db()?;
|
let db = self.user.collab_db()?;
|
||||||
let collab = self.collab_builder.build(uid, &doc_id, "document", db);
|
let collab = self.collab_builder.build(uid, doc_id, "document", db);
|
||||||
let data = data.unwrap_or_else(default_document_data);
|
let data = data.unwrap_or_else(default_document_data);
|
||||||
let document = Arc::new(Document::create_with_data(collab, data)?);
|
let document = Arc::new(Document::create_with_data(collab, data)?);
|
||||||
Ok(document)
|
Ok(document)
|
||||||
@ -56,22 +57,34 @@ impl DocumentManager {
|
|||||||
|
|
||||||
/// get document
|
/// get document
|
||||||
/// read the existing document from the map if it exists, otherwise read it from the disk and write it to the map.
|
/// read the existing document from the map if it exists, otherwise read it from the disk and write it to the map.
|
||||||
pub fn get_or_open_document(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
|
pub fn get_or_open_document(&self, doc_id: &str) -> FlowyResult<Arc<Document>> {
|
||||||
if let Some(doc) = self.documents.read().get(&doc_id) {
|
if let Some(doc) = self.documents.read().get(doc_id) {
|
||||||
return Ok(doc.clone());
|
return Ok(doc.clone());
|
||||||
}
|
}
|
||||||
tracing::debug!("open_document: {:?}", &doc_id);
|
tracing::debug!("open_document: {:?}", doc_id);
|
||||||
// read the existing document from the disk.
|
// read the existing document from the disk.
|
||||||
let document = self.get_document_from_disk(doc_id.clone())?;
|
let document = self.get_document_from_disk(&doc_id)?;
|
||||||
// save the document to the memory and read it from the memory if we open the same document again.
|
// save the document to the memory and read it from the memory if we open the same document again.
|
||||||
// and we don't want to subscribe to the document changes if we open the same document again.
|
// and we don't want to subscribe to the document changes if we open the same document again.
|
||||||
self
|
self
|
||||||
.documents
|
.documents
|
||||||
.write()
|
.write()
|
||||||
.insert(doc_id.clone(), document.clone());
|
.insert(doc_id.to_string(), document.clone());
|
||||||
|
|
||||||
// subscribe to the document changes.
|
// subscribe to the document changes.
|
||||||
document.lock().open(move |events, is_remote| {
|
self.subscribe_document_changes(document.clone(), doc_id)?;
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe_document_changes(
|
||||||
|
&self,
|
||||||
|
document: Arc<Document>,
|
||||||
|
doc_id: &str,
|
||||||
|
) -> Result<DocumentData, DocumentError> {
|
||||||
|
let mut document = document.lock();
|
||||||
|
let doc_id = doc_id.to_string();
|
||||||
|
document.open(move |events, is_remote| {
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
"document changed: {:?}, from remote: {}",
|
"document changed: {:?}, from remote: {}",
|
||||||
&events,
|
&events,
|
||||||
@ -81,17 +94,15 @@ impl DocumentManager {
|
|||||||
send_notification(&doc_id, DocumentNotification::DidReceiveUpdate)
|
send_notification(&doc_id, DocumentNotification::DidReceiveUpdate)
|
||||||
.payload::<DocEventPB>((events, is_remote).into())
|
.payload::<DocEventPB>((events, is_remote).into())
|
||||||
.send();
|
.send();
|
||||||
})?;
|
})
|
||||||
|
|
||||||
Ok(document)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// get document
|
/// get document
|
||||||
/// read the existing document from the disk.
|
/// read the existing document from the disk.
|
||||||
pub fn get_document_from_disk(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
|
pub fn get_document_from_disk(&self, doc_id: &str) -> FlowyResult<Arc<Document>> {
|
||||||
let uid = self.user.user_id()?;
|
let uid = self.user.user_id()?;
|
||||||
let db = self.user.collab_db()?;
|
let db = self.user.collab_db()?;
|
||||||
let collab = self.collab_builder.build(uid, &doc_id, "document", db);
|
let collab = self.collab_builder.build(uid, doc_id, "document", db);
|
||||||
// read the existing document from the disk.
|
// read the existing document from the disk.
|
||||||
let document = Arc::new(Document::new(collab)?);
|
let document = Arc::new(Document::new(collab)?);
|
||||||
Ok(document)
|
Ok(document)
|
||||||
|
@ -15,10 +15,10 @@ async fn undo_redo_test() {
|
|||||||
let data = default_document_data();
|
let data = default_document_data();
|
||||||
|
|
||||||
// create a document
|
// create a document
|
||||||
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
|
_ = manager.create_document(&doc_id, Some(data.clone()));
|
||||||
|
|
||||||
// open a document
|
// open a document
|
||||||
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
let document = document.lock();
|
let document = document.lock();
|
||||||
let page_block = document.get_block(&data.page_id).unwrap();
|
let page_block = document.get_block(&data.page_id).unwrap();
|
||||||
let page_id = page_block.id;
|
let page_id = page_block.id;
|
||||||
|
@ -20,14 +20,14 @@ fn restore_document() {
|
|||||||
let doc_id: String = gen_document_id();
|
let doc_id: String = gen_document_id();
|
||||||
let data = default_document_data();
|
let data = default_document_data();
|
||||||
let document_a = manager
|
let document_a = manager
|
||||||
.create_document(doc_id.clone(), Some(data.clone()))
|
.create_document(&doc_id, Some(data.clone()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let data_a = document_a.lock().get_document().unwrap();
|
let data_a = document_a.lock().get_document().unwrap();
|
||||||
assert_eq!(data_a, data);
|
assert_eq!(data_a, data);
|
||||||
|
|
||||||
// open a document
|
// open a document
|
||||||
let data_b = manager
|
let data_b = manager
|
||||||
.get_or_open_document(doc_id.clone())
|
.get_or_open_document(&doc_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.lock()
|
.lock()
|
||||||
.get_document()
|
.get_document()
|
||||||
@ -37,10 +37,10 @@ fn restore_document() {
|
|||||||
assert_eq!(data_b, data);
|
assert_eq!(data_b, data);
|
||||||
|
|
||||||
// restore
|
// restore
|
||||||
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
|
_ = manager.create_document(&doc_id, Some(data.clone()));
|
||||||
// open a document
|
// open a document
|
||||||
let data_b = manager
|
let data_b = manager
|
||||||
.get_or_open_document(doc_id.clone())
|
.get_or_open_document(&doc_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.lock()
|
.lock()
|
||||||
.get_document()
|
.get_document()
|
||||||
@ -60,10 +60,10 @@ fn document_apply_insert_action() {
|
|||||||
let data = default_document_data();
|
let data = default_document_data();
|
||||||
|
|
||||||
// create a document
|
// create a document
|
||||||
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
|
_ = manager.create_document(&doc_id, Some(data.clone()));
|
||||||
|
|
||||||
// open a document
|
// open a document
|
||||||
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
let page_block = document.lock().get_block(&data.page_id).unwrap();
|
let page_block = document.lock().get_block(&data.page_id).unwrap();
|
||||||
|
|
||||||
// insert a text block
|
// insert a text block
|
||||||
@ -91,7 +91,7 @@ fn document_apply_insert_action() {
|
|||||||
|
|
||||||
// re-open the document
|
// re-open the document
|
||||||
let data_b = manager
|
let data_b = manager
|
||||||
.get_or_open_document(doc_id.clone())
|
.get_or_open_document(&doc_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.lock()
|
.lock()
|
||||||
.get_document()
|
.get_document()
|
||||||
@ -111,10 +111,10 @@ fn document_apply_update_page_action() {
|
|||||||
let data = default_document_data();
|
let data = default_document_data();
|
||||||
|
|
||||||
// create a document
|
// create a document
|
||||||
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
|
_ = manager.create_document(&doc_id, Some(data.clone()));
|
||||||
|
|
||||||
// open a document
|
// open a document
|
||||||
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
let page_block = document.lock().get_block(&data.page_id).unwrap();
|
let page_block = document.lock().get_block(&data.page_id).unwrap();
|
||||||
|
|
||||||
let mut page_block_clone = page_block;
|
let mut page_block_clone = page_block;
|
||||||
@ -138,7 +138,7 @@ fn document_apply_update_page_action() {
|
|||||||
_ = manager.close_document(&doc_id);
|
_ = manager.close_document(&doc_id);
|
||||||
|
|
||||||
// re-open the document
|
// re-open the document
|
||||||
let document = manager.get_or_open_document(doc_id).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
let page_block_new = document.lock().get_block(&data.page_id).unwrap();
|
let page_block_new = document.lock().get_block(&data.page_id).unwrap();
|
||||||
assert_eq!(page_block_old, page_block_new);
|
assert_eq!(page_block_old, page_block_new);
|
||||||
assert!(page_block_new.data.contains_key("delta"));
|
assert!(page_block_new.data.contains_key("delta"));
|
||||||
@ -153,10 +153,10 @@ fn document_apply_update_action() {
|
|||||||
let data = default_document_data();
|
let data = default_document_data();
|
||||||
|
|
||||||
// create a document
|
// create a document
|
||||||
_ = manager.create_document(doc_id.clone(), Some(data.clone()));
|
_ = manager.create_document(&doc_id, Some(data.clone()));
|
||||||
|
|
||||||
// open a document
|
// open a document
|
||||||
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
let page_block = document.lock().get_block(&data.page_id).unwrap();
|
let page_block = document.lock().get_block(&data.page_id).unwrap();
|
||||||
|
|
||||||
// insert a text block
|
// insert a text block
|
||||||
@ -206,7 +206,7 @@ fn document_apply_update_action() {
|
|||||||
_ = manager.close_document(&doc_id);
|
_ = manager.close_document(&doc_id);
|
||||||
|
|
||||||
// re-open the document
|
// re-open the document
|
||||||
let document = manager.get_or_open_document(doc_id.clone()).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
let block = document.lock().get_block(&text_block_id).unwrap();
|
let block = document.lock().get_block(&text_block_id).unwrap();
|
||||||
assert_eq!(block.data, updated_text_block_data);
|
assert_eq!(block.data, updated_text_block_data);
|
||||||
// close a document
|
// close a document
|
||||||
|
@ -66,10 +66,10 @@ pub fn create_and_open_empty_document() -> (DocumentManager, Arc<Document>, Stri
|
|||||||
|
|
||||||
// create a document
|
// create a document
|
||||||
_ = manager
|
_ = manager
|
||||||
.create_document(doc_id.clone(), Some(data.clone()))
|
.create_document(&doc_id, Some(data.clone()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let document = manager.get_or_open_document(doc_id).unwrap();
|
let document = manager.get_or_open_document(&doc_id).unwrap();
|
||||||
|
|
||||||
(manager, document, data.page_id)
|
(manager, document, data.page_id)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user