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