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:
Kilu.He 2023-06-17 14:25:30 +08:00 committed by GitHub
parent d986e01d3e
commit 177f7c4fa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 1794 additions and 752 deletions

View File

@ -57,6 +57,16 @@ module.exports = {
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
} }
], ],
'padding-line-between-statements': [
"warn",
{ blankLine: "always", prev: ["const", "let", "var"], next: "*"},
{ blankLine: "any", prev: ["const", "let", "var"], next: ["const", "let", "var"]},
{ blankLine: "always", prev: "import", next: "*" },
{ blankLine: "any", prev: "import", next: "import" },
{ blankLine: "always", prev: "block-like", next: "*" },
{ blankLine: "always", prev: "block", next: "*" },
]
}, },
ignorePatterns: ['src/**/*.test.ts'], ignorePatterns: ['src/**/*.test.ts'],
}; };

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { rangeActions } from '$app_reducers/document/slice'; import { rangeActions } from '$app_reducers/document/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { import {
getBlockIdByPoint, getBlockIdByPoint,
getNodeTextBoxByBlockId, getNodeTextBoxByBlockId,
@ -9,12 +9,16 @@ import {
setCursorAtStartOfNode, setCursorAtStartOfNode,
} from '$app/utils/document/node'; } from '$app/utils/document/node';
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks'; import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
export function useBlockRangeSelection(container: HTMLDivElement) { export function useBlockRangeSelection(container: HTMLDivElement) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onKeyDown = useRangeKeyDown(); const onKeyDown = useRangeKeyDown();
const range = useAppSelector((state) => state.documentRange); const { docId } = useSubscribeDocument();
const isDragging = range.isDragging;
const range = useSubscribeRanges();
const isDragging = range?.isDragging;
const anchorRef = useRef<{ const anchorRef = useRef<{
id: string; id: string;
@ -28,13 +32,9 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const [isForward, setForward] = useState(true); const [isForward, setForward] = useState(true);
const reset = useCallback(() => {
dispatch(rangeActions.clearRange());
setForward(true);
}, [dispatch]);
// display caret color // display caret color
useEffect(() => { useEffect(() => {
if (!range) return;
const { anchor, focus } = range; const { anchor, focus } = range;
if (!anchor || !focus) { if (!anchor || !focus) {
container.classList.remove('caret-transparent'); container.classList.remove('caret-transparent');
@ -54,7 +54,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection) return; if (!selection) return;
// update focus point // update focus point
dispatch(rangeActions.setFocusPoint(focus)); dispatch(
rangeActions.setFocusPoint({
...focus,
docId,
})
);
const focused = isFocused(focus.id); const focused = isFocused(focus.id);
// if the focus block is not focused, we need to set the cursor position // if the focus block is not focused, we need to set the cursor position
@ -82,17 +87,18 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
setCursorAtEndOfNode(node); setCursorAtEndOfNode(node);
} }
} }
}, [container, dispatch, focus, isForward]); }, [container, dispatch, docId, focus, isForward]);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
reset(); setForward(true);
// skip if the target is not a block // skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement); const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) { if (!blockId) {
dispatch(rangeActions.initialState(docId));
return; return;
} }
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
const startX = e.clientX + container.scrollLeft; const startX = e.clientX + container.scrollLeft;
const startY = e.clientY + container.scrollTop; const startY = e.clientY + container.scrollTop;
@ -108,11 +114,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
...anchor, ...anchor,
}; };
// set the anchor point and focus point // set the anchor point and focus point
dispatch(rangeActions.setAnchorPoint({ ...anchor })); dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
dispatch(rangeActions.setFocusPoint({ ...anchor })); dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
dispatch(rangeActions.setDragging(true)); dispatch(
rangeActions.setDragging({
isDragging: true,
docId,
})
);
return;
}, },
[container.scrollLeft, container.scrollTop, dispatch, reset] [container.scrollLeft, container.scrollTop, dispatch, docId]
); );
const handleDraging = useCallback( const handleDraging = useCallback(
@ -152,8 +164,13 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
if (!isDragging) return; if (!isDragging) return;
setFocus(null); setFocus(null);
anchorRef.current = null; anchorRef.current = null;
dispatch(rangeActions.setDragging(false)); dispatch(
}, [dispatch, isDragging]); rangeActions.setDragging({
isDragging: false,
docId,
})
);
}, [docId, dispatch, isDragging]);
useEffect(() => { useEffect(() => {
document.addEventListener('mousedown', handleDragStart); document.addEventListener('mousedown', handleDragStart);
@ -164,9 +181,10 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
document.removeEventListener('mousedown', handleDragStart); document.removeEventListener('mousedown', handleDragStart);
document.removeEventListener('mousemove', handleDraging); document.removeEventListener('mousemove', handleDraging);
document.removeEventListener('mouseup', handleDragEnd); document.removeEventListener('mouseup', handleDragEnd);
container.removeEventListener('keydown', onKeyDown, true); container.removeEventListener('keydown', onKeyDown, true);
}; };
}, [reset, handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]); }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
return null; return null;
} }

View File

@ -4,6 +4,7 @@ import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/sl
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
import { isPointInBlock } from '$app/utils/document/node'; import { isPointInBlock } from '$app/utils/document/node';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export interface BlockRectSelectionProps { export interface BlockRectSelectionProps {
container: HTMLDivElement; container: HTMLDivElement;
@ -12,13 +13,19 @@ export interface BlockRectSelectionProps {
export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) { export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { docId } = useSubscribeDocument();
const [isDragging, setDragging] = useState(false); const [isDragging, setDragging] = useState(false);
const startPointRef = useRef<number[]>([]); const startPointRef = useRef<number[]>([]);
useEffect(() => { useEffect(() => {
dispatch(rectSelectionActions.setDragging(isDragging)); dispatch(
}, [dispatch, isDragging]); rectSelectionActions.setDragging({
docId,
isDragging,
})
);
}, [docId, dispatch, isDragging]);
const [rect, setRect] = useState<{ const [rect, setRect] = useState<{
startX: number; startX: number;
@ -78,9 +85,14 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
}; };
const blockIds = getIntersectedBlockIds(newRect); const blockIds = getIntersectedBlockIds(newRect);
setRect(newRect); setRect(newRect);
dispatch(setRectSelectionThunk(blockIds)); dispatch(
setRectSelectionThunk({
selection: blockIds,
docId,
})
);
}, },
[container.scrollLeft, container.scrollTop, dispatch, getIntersectedBlockIds, isDragging] [container.scrollLeft, container.scrollTop, dispatch, docId, getIntersectedBlockIds, isDragging]
); );
const handleDraging = useCallback( const handleDraging = useCallback(
@ -105,7 +117,12 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (isPointInBlock(e.target as HTMLElement) && !isDragging) { if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
dispatch(rectSelectionActions.updateSelections([])); dispatch(
rectSelectionActions.updateSelections({
docId,
selection: [],
})
);
return; return;
} }
if (!isDragging) return; if (!isDragging) return;
@ -114,7 +131,7 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
setDragging(false); setDragging(false);
setRect(null); setRect(null);
}, },
[dispatch, isDragging, updateSelctionsByPoint] [dispatch, docId, isDragging, updateSelctionsByPoint]
); );
useEffect(() => { useEffect(() => {

View File

@ -1,10 +1,9 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppSelector } from '$app/stores/store';
import { RegionGrid } from '$app/utils/region_grid'; import { RegionGrid } from '$app/utils/region_grid';
import { useSubscribeDocument, useSubscribeDocumentData } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useNodesRect(container: HTMLDivElement) { export function useNodesRect(container: HTMLDivElement) {
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const version = useVersionUpdate(); const version = useVersionUpdate();
@ -75,9 +74,7 @@ export function useNodesRect(container: HTMLDivElement) {
function useVersionUpdate() { function useVersionUpdate() {
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const data = useAppSelector((state) => { const data = useSubscribeDocumentData();
return state.document;
});
useEffect(() => { useEffect(() => {
setVersion((v) => { setVersion((v) => {

View File

@ -1,7 +1,6 @@
import { useCallback, useContext, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Keyboard } from '$app/constants/document/keyboard'; import { Keyboard } from '$app/constants/document/keyboard';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions'; import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
@ -10,12 +9,14 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
import { isPrintableKeyEvent } from '$app/utils/document/action'; import { isPrintableKeyEvent } from '$app/utils/document/action';
import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useRangeKeyDown() { export function useRangeKeyDown() {
const rangeRef = useRangeRef(); const rangeRef = useRangeRef();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const interceptEvents = useMemo( const interceptEvents = useMemo(
() => [ () => [
{ {
@ -92,6 +93,7 @@ export function useRangeKeyDown() {
dispatch( dispatch(
arrowActionForRangeThunk({ arrowActionForRangeThunk({
key: e.key, key: e.key,
docId,
}) })
); );
}, },
@ -112,7 +114,7 @@ export function useRangeKeyDown() {
}, },
}, },
], ],
[controller, dispatch] [controller, dispatch, docId]
); );
const onKeyDownCapture = useCallback( const onKeyDownCapture = useCallback(

View File

@ -1,12 +1,12 @@
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react'; import { useCallback } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate'; import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate';
import { deleteNodeThunk } from '$app_reducers/document/async-actions'; import { deleteNodeThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useBlockMenu(id: string) { export function useBlockMenu(id: string) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const handleDuplicate = useCallback(async () => { const handleDuplicate = useCallback(async () => {
if (!controller) return; if (!controller) return;

View File

@ -1,14 +1,37 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { List } from '@mui/material';
import { ContentCopy, Delete } from '@mui/icons-material'; import { ContentCopy, Delete } from '@mui/icons-material';
import MenuItem from './MenuItem'; import MenuItem from '../_shared/MenuItem';
import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks'; import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks';
import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto'; import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto';
import TextField from '@mui/material/TextField';
import { Keyboard } from '$app/constants/document/keyboard';
import { selectOptionByUpDown } from '$app/utils/document/menu';
enum BlockMenuOption {
Duplicate = 'Duplicate',
Delete = 'Delete',
TurnInto = 'TurnInto',
}
interface Option {
operate?: () => Promise<void>;
title?: string;
icon?: React.ReactNode;
key: BlockMenuOption;
openNextMenu?: boolean;
}
function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
const { handleDelete, handleDuplicate } = useBlockMenu(id); const { handleDelete, handleDuplicate } = useBlockMenu(id);
const [subMenuOpened, setSubMenuOpened] = useState(false);
const [hovered, setHovered] = useState<BlockMenuOption | null>(null);
useEffect(() => {
if (hovered !== BlockMenuOption.TurnInto) {
setSubMenuOpened(false);
}
}, [hovered]);
const [turnIntoOptionHovered, setTurnIntoOptionHorvered] = useState<boolean>(false);
const handleClick = useCallback( const handleClick = useCallback(
async ({ operate }: { operate: () => Promise<void> }) => { async ({ operate }: { operate: () => Promise<void> }) => {
await operate(); await operate();
@ -17,52 +40,119 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
[onClose] [onClose]
); );
const options: Option[] = useMemo(
() => [
{
operate: () => {
return handleClick({ operate: handleDelete });
},
title: 'Delete',
icon: <Delete />,
key: BlockMenuOption.Delete,
},
{
operate: () => {
return handleClick({ operate: handleDuplicate });
},
title: 'Duplicate',
icon: <ContentCopy />,
key: BlockMenuOption.Duplicate,
},
{
key: BlockMenuOption.TurnInto,
},
],
[handleClick, handleDelete, handleDuplicate]
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const isUp = e.key === Keyboard.keys.UP;
const isDown = e.key === Keyboard.keys.DOWN;
const isLeft = e.key === Keyboard.keys.LEFT;
const isRight = e.key === Keyboard.keys.RIGHT;
const isEnter = e.key === Keyboard.keys.ENTER;
const isArrow = isUp || isDown || isLeft || isRight;
if (!isArrow && !isEnter) return;
e.stopPropagation();
if (isEnter) {
if (hovered) {
const option = options.find((option) => option.key === hovered);
if (option) {
option.operate?.();
}
} else {
onClose();
}
return;
}
const optionsKeys = options.map((option) => option.key);
if (isUp || isDown) {
const nextKey = selectOptionByUpDown(isUp, hovered, optionsKeys);
const nextOption = options.find((option) => option.key === nextKey);
setHovered(nextOption?.key ?? null);
return;
}
if (isLeft || isRight) {
if (hovered === BlockMenuOption.TurnInto) {
setSubMenuOpened(isRight);
}
}
},
[hovered, onClose, options]
);
return ( return (
<List <div
tabIndex={1}
onKeyDown={onKeyDown}
onMouseDown={(e) => { onMouseDown={(e) => {
// Prevent the block from being selected.
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
> >
{/** Delete option in the BlockMenu. */} <div className={'p-2'}>
<MenuItem <TextField autoFocus label='Search' placeholder='Search actions...' variant='standard' />
title='Delete' </div>
icon={<Delete />} {options.map((option) => {
onClick={() => if (option.key === BlockMenuOption.TurnInto) {
handleClick({ return (
operate: handleDelete, <BlockMenuTurnInto
}) key={option.key}
onHovered={() => {
setHovered(BlockMenuOption.TurnInto);
setSubMenuOpened(true);
}}
menuOpened={subMenuOpened}
isHovered={hovered === BlockMenuOption.TurnInto}
onClose={() => setSubMenuOpened(false)}
id={id}
/>
);
} }
onHover={(isHovered) => {
if (isHovered) { return (
setTurnIntoOptionHorvered(false); <MenuItem
} key={option.key}
}} title={option.title}
/> icon={option.icon}
{/** Duplicate option in the BlockMenu. */} isHovered={hovered === option.key}
<MenuItem onClick={option.operate}
title='Duplicate' onHover={() => {
icon={<ContentCopy />} setHovered(option.key);
onClick={() => setSubMenuOpened(false);
handleClick({ }}
operate: handleDuplicate, />
}) );
} })}
onHover={(isHovered) => { </div>
if (isHovered) {
setTurnIntoOptionHorvered(false);
}
}}
/>
{/** Turn Into option in the BlockMenu. */}
<BlockMenuTurnInto
onHovered={() => setTurnIntoOptionHorvered(true)}
isHovered={turnIntoOptionHovered}
onClose={onClose}
id={id}
/>
</List>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { MouseEvent, useRef } from 'react';
import { ArrowRight, Transform } from '@mui/icons-material'; import { ArrowRight, Transform } from '@mui/icons-material';
import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem'; import MenuItem from '$app/components/document/_shared/MenuItem';
import TurnIntoPopover from '$app/components/document/_shared/TurnInto'; import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
function BlockMenuTurnInto({ function BlockMenuTurnInto({
@ -8,28 +8,27 @@ function BlockMenuTurnInto({
onClose, onClose,
onHovered, onHovered,
isHovered, isHovered,
menuOpened,
}: { }: {
id: string; id: string;
onClose: () => void; onClose: () => void;
onHovered: () => void; onHovered: (e: MouseEvent) => void;
isHovered: boolean; isHovered: boolean;
menuOpened: boolean;
}) { }) {
const [anchorEl, setAnchorEl] = useState<null | HTMLDivElement>(null); const ref = useRef<HTMLDivElement | null>(null);
const open = isHovered && menuOpened && Boolean(ref.current);
const open = isHovered && Boolean(anchorEl);
return ( return (
<> <>
<MenuItem <MenuItem
ref={ref}
title='Turn into' title='Turn into'
isHovered={isHovered}
icon={<Transform />} icon={<Transform />}
extra={<ArrowRight />} extra={<ArrowRight />}
onHover={(hovered, event) => { onHover={(e) => {
if (hovered) { onHovered(e);
onHovered();
setAnchorEl(event.currentTarget);
return;
}
}} }}
/> />
<TurnIntoPopover <TurnIntoPopover
@ -46,7 +45,7 @@ function BlockMenuTurnInto({
}, },
}} }}
onClose={onClose} onClose={onClose}
anchorEl={anchorEl} anchorEl={ref.current}
anchorOrigin={{ anchorOrigin={{
vertical: 'center', vertical: 'center',
horizontal: 'right', horizontal: 'right',

View File

@ -3,23 +3,28 @@ import { useAppDispatch } from '@/appflowy_app/stores/store';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { PopoverOrigin } from '@mui/material/Popover/Popover'; import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
const headingBlockTopOffset: Record<number, number> = { const headingBlockTopOffset: Record<number, number> = {
1: 7, 1: 7,
2: 5, 2: 5,
3: 4, 3: 4,
}; };
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) { export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
const [nodeId, setHoverNodeId] = useState<string | null>(null); const [nodeId, setHoverNodeId] = useState<string | null>(null);
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [style, setStyle] = useState<React.CSSProperties>({}); const [style, setStyle] = useState<React.CSSProperties>({});
const { docId } = useSubscribeDocument();
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el || !nodeId) return; if (!el || !nodeId) return;
void (async () => { void (async () => {
const node = getBlock(nodeId); const node = getBlock(docId, nodeId);
if (!node) { if (!node) {
setStyle({ setStyle({
opacity: '0', opacity: '0',
@ -31,6 +36,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
if (node.type === BlockType.HeadingBlock) { if (node.type === BlockType.HeadingBlock) {
const nodeData = node.data as HeadingBlockData; const nodeData = node.data as HeadingBlockData;
top = headingBlockTopOffset[nodeData.level]; top = headingBlockTopOffset[nodeData.level];
} }
@ -41,11 +47,12 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
}); });
} }
})(); })();
}, [dispatch, nodeId]); }, [dispatch, docId, nodeId]);
const handleMouseMove = useCallback((e: MouseEvent) => { const handleMouseMove = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e; const { clientX, clientY } = e;
const id = getNodeIdByPoint(clientX, clientY); const id = getNodeIdByPoint(clientX, clientY);
setHoverNodeId(id); setHoverNodeId(id);
}, []); }, []);
@ -69,6 +76,7 @@ function getNodeIdByPoint(x: number, y: number) {
el: Element; el: Element;
rect: DOMRect; rect: DOMRect;
} | null = null; } | null = null;
viewportNodes.forEach((el) => { viewportNodes.forEach((el) => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@ -104,6 +112,7 @@ const origin: {
horizontal: 'left', horizontal: 'left',
}, },
}; };
export function usePopover() { export function usePopover() {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null); const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
@ -123,7 +132,6 @@ export function usePopover() {
onClose, onClose,
open, open,
handleOpen, handleOpen,
disableAutoFocus: true,
...origin, ...origin,
}; };
} }

View File

@ -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;

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React from 'react';
import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks'; import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
import Portal from '../BlockPortal'; import Portal from '../BlockPortal';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
@ -9,13 +9,16 @@ import BlockMenu from './BlockMenu';
import ToolbarButton from './ToolbarButton'; import ToolbarButton from './ToolbarButton';
import { rectSelectionActions } from '$app_reducers/document/slice'; import { rectSelectionActions } from '$app_reducers/document/slice';
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu'; import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) { export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const { nodeId, style, ref } = useBlockSideToolbar({ container }); const { nodeId, style, ref } = useBlockSideToolbar({ container });
const isDragging = useAppSelector((state) => state.documentRange.isDragging || state.documentRectSelection.isDragging); const isDragging = useAppSelector(
(state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
);
const { handleOpen, ...popoverProps } = usePopover(); const { handleOpen, ...popoverProps } = usePopover();
// prevent popover from showing when anchorEl is not in DOM // prevent popover from showing when anchorEl is not in DOM
@ -60,7 +63,12 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
tooltip={'Click to open Menu'} tooltip={'Click to open Menu'}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => { onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
if (!nodeId) return; if (!nodeId) return;
dispatch(rectSelectionActions.setSelectionById(nodeId)); dispatch(
rectSelectionActions.setSelectionById({
docId,
blockId: nodeId,
})
);
handleOpen(e); handleOpen(e);
}} }}
> >

View File

@ -1,5 +1,5 @@
import React, { useCallback, useContext, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem'; import MenuItem from '$app/components/document/_shared/MenuItem';
import { import {
ArrowRight, ArrowRight,
Check, Check,
@ -12,15 +12,36 @@ import {
Title, Title,
SafetyDivider, SafetyDivider,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { List } from '@mui/material'; import {
import { BlockData, BlockType } from '$app/interfaces/document'; BlockData,
BlockType,
SlashCommandGroup,
SlashCommandOption,
SlashCommandOptionKey,
} from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu'; import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { slashCommandActions } from '$app_reducers/document/slice';
import { Keyboard } from '$app/constants/document/keyboard';
import { selectOptionByUpDown } from '$app/utils/document/menu';
function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: () => void; searchText?: string }) { function BlockSlashMenu({
id,
onClose,
searchText,
hoverOption,
container,
}: {
id: string;
onClose?: () => void;
searchText?: string;
hoverOption?: SlashCommandOption;
container: HTMLDivElement;
}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const ref = useRef<HTMLDivElement | null>(null);
const { docId, controller } = useSubscribeDocument();
const handleInsert = useCallback( const handleInsert = useCallback(
async (type: BlockType, data?: BlockData<any>) => { async (type: BlockType, data?: BlockData<any>) => {
if (!controller) return; if (!controller) return;
@ -39,108 +60,245 @@ function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: ()
[controller, dispatch, id, onClose] [controller, dispatch, id, onClose]
); );
const optionColumns = useMemo( const options: (SlashCommandOption & {
() => [ title: string;
icon: React.ReactNode;
group: SlashCommandGroup;
})[] = useMemo(
() =>
[ [
{ {
key: SlashCommandOptionKey.TEXT,
type: BlockType.TextBlock, type: BlockType.TextBlock,
title: 'Text', title: 'Text',
icon: <TextFields />, icon: <TextFields />,
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.HEADING_1,
type: BlockType.HeadingBlock, type: BlockType.HeadingBlock,
title: 'Heading 1', title: 'Heading 1',
icon: <Title />, icon: <Title />,
props: { data: {
level: 1, level: 1,
}, },
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.HEADING_2,
type: BlockType.HeadingBlock, type: BlockType.HeadingBlock,
title: 'Heading 2', title: 'Heading 2',
icon: <Title />, icon: <Title />,
props: { data: {
level: 2, level: 2,
}, },
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.HEADING_3,
type: BlockType.HeadingBlock, type: BlockType.HeadingBlock,
title: 'Heading 3', title: 'Heading 3',
icon: <Title />, icon: <Title />,
props: { data: {
level: 3, level: 3,
}, },
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.TODO,
type: BlockType.TodoListBlock, type: BlockType.TodoListBlock,
title: 'To-do list', title: 'To-do list',
icon: <Check />, icon: <Check />,
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.BULLET,
type: BlockType.BulletedListBlock, type: BlockType.BulletedListBlock,
title: 'Bulleted list', title: 'Bulleted list',
icon: <FormatListBulleted />, icon: <FormatListBulleted />,
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.NUMBER,
type: BlockType.NumberedListBlock, type: BlockType.NumberedListBlock,
title: 'Numbered list', title: 'Numbered list',
icon: <FormatListNumbered />, icon: <FormatListNumbered />,
group: SlashCommandGroup.BASIC,
}, },
],
[
{ {
key: SlashCommandOptionKey.TOGGLE,
type: BlockType.ToggleListBlock, type: BlockType.ToggleListBlock,
title: 'Toggle list', title: 'Toggle list',
icon: <ArrowRight />, icon: <ArrowRight />,
group: SlashCommandGroup.BASIC,
}, },
{ {
type: BlockType.CodeBlock, key: SlashCommandOptionKey.QUOTE,
title: 'Code',
icon: <DataObject />,
},
{
type: BlockType.QuoteBlock, type: BlockType.QuoteBlock,
title: 'Quote', title: 'Quote',
icon: <FormatQuote />, icon: <FormatQuote />,
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.CALLOUT,
type: BlockType.CalloutBlock, type: BlockType.CalloutBlock,
title: 'Callout', title: 'Callout',
icon: <Lightbulb />, icon: <Lightbulb />,
group: SlashCommandGroup.BASIC,
}, },
{ {
key: SlashCommandOptionKey.DIVIDER,
type: BlockType.DividerBlock, type: BlockType.DividerBlock,
title: 'Divider', title: 'Divider',
icon: <SafetyDivider />, icon: <SafetyDivider />,
group: SlashCommandGroup.BASIC,
}, },
], {
], key: SlashCommandOptionKey.CODE,
[] type: BlockType.CodeBlock,
title: 'Code',
icon: <DataObject />,
group: SlashCommandGroup.MEDIA,
},
].filter((option) => {
if (!searchText) return true;
const match = (text: string) => {
return text.toLowerCase().includes(searchText.toLowerCase());
};
return match(option.title) || match(option.type);
}),
[searchText]
); );
const optionsByGroup = useMemo(() => {
return options.reduce((acc, option) => {
if (!acc[option.group]) {
acc[option.group] = [];
}
acc[option.group].push(option);
return acc;
}, {} as Record<SlashCommandGroup, typeof options>);
}, [options]);
const scrollIntoOption = useCallback((option: SlashCommandOption) => {
if (!ref.current) return;
const containerRect = ref.current.getBoundingClientRect();
const optionElement = document.querySelector(`#slash-item-${option.key}`);
if (!optionElement) return;
const itemRect = optionElement?.getBoundingClientRect();
if (!itemRect) return;
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
optionElement.scrollIntoView({ behavior: 'smooth' });
}
}, []);
const selectOptionByArrow = useCallback(
({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => {
if (!isUp && !isDown) return;
const optionsKeys = options.map((option) => String(option.key));
const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys);
const nextOption = options.find((option) => String(option.key) === nextKey);
if (!nextOption) return;
scrollIntoOption(nextOption);
dispatch(
slashCommandActions.setHoverOption({
option: nextOption,
docId,
})
);
},
[dispatch, docId, hoverOption?.key, options, scrollIntoOption]
);
useEffect(() => {
const handleKeyDownCapture = (e: KeyboardEvent) => {
const isUp = e.key === Keyboard.keys.UP;
const isDown = e.key === Keyboard.keys.DOWN;
const isEnter = e.key === Keyboard.keys.ENTER;
// if any arrow key is pressed, prevent default behavior and stop propagation
if (isUp || isDown || isEnter) {
e.stopPropagation();
e.preventDefault();
if (isEnter) {
if (hoverOption) {
handleInsert(hoverOption.type, hoverOption.data);
}
return;
}
selectOptionByArrow({
isUp,
isDown,
});
}
};
// intercept keydown event in capture phase before it reaches the editor
container.addEventListener('keydown', handleKeyDownCapture, true);
return () => {
container.removeEventListener('keydown', handleKeyDownCapture, true);
};
}, [container, handleInsert, hoverOption, selectOptionByArrow]);
const onHoverOption = useCallback(
(option: SlashCommandOption) => {
dispatch(
slashCommandActions.setHoverOption({
option: {
key: option.key,
type: option.type,
data: option.data,
},
docId,
})
);
},
[dispatch, docId]
);
return ( return (
<div <div
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
className={'flex'} className={'flex h-[100%] max-h-[40vh] w-[324px] min-w-[180px] max-w-[calc(100vw-32px)] flex-col p-1'}
> >
{optionColumns.map((column, index) => ( <div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
<List key={index} className={'flex-1'}> {Object.entries(optionsByGroup).map(([group, options]) => (
{column.map((option) => { <div key={group}>
return ( <div className={'px-2 py-2 text-sm text-shade-3'}>{group}</div>
<MenuItem <div>
key={option.title} {options.map((option) => {
title={option.title} return (
icon={option.icon} <MenuItem
onClick={() => { id={`slash-item-${option.key}`}
handleInsert(option.type, option.props); key={option.key}
}} title={option.title}
/> icon={option.icon}
); onHover={() => {
})} onHoverOption(option);
</List> }}
))} isHovered={hoverOption?.key === option.key}
onClick={() => {
handleInsert(option.type, option.data);
}}
/>
);
})}
</div>
</div>
))}
</div>
</div> </div>
); );
} }

View File

@ -1,16 +1,23 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { slashCommandActions } from '$app_reducers/document/slice'; import { slashCommandActions } from '$app_reducers/document/slice';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { Op } from 'quill-delta'; import { Op } from 'quill-delta';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
export function useBlockSlash() { export function useBlockSlash() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { blockId, visible, slashText } = useSubscribeSlash(); const { docId } = useSubscribeDocument();
const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
if (blockId && visible) { if (blockId && visible) {
const el = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement; const blockEl = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
const el = blockEl.querySelector(`[role="textbox"]`) as HTMLElement;
if (el) { if (el) {
setAnchorEl(el); setAnchorEl(el);
return; return;
@ -21,18 +28,20 @@ export function useBlockSlash() {
useEffect(() => { useEffect(() => {
if (!slashText) { if (!slashText) {
dispatch(slashCommandActions.closeSlashCommand()); dispatch(slashCommandActions.closeSlashCommand(docId));
} }
}, [dispatch, slashText]); }, [dispatch, docId, slashText]);
const searchText = useMemo(() => { const searchText = useMemo(() => {
if (!slashText) return ''; if (!slashText) return '';
if (slashText[0] !== '/') return slashText; if (slashText[0] !== '/') return slashText;
return slashText.slice(1, slashText.length); return slashText.slice(1, slashText.length);
}, [slashText]); }, [slashText]);
const onClose = useCallback(() => { const onClose = useCallback(() => {
dispatch(slashCommandActions.closeSlashCommand()); dispatch(slashCommandActions.closeSlashCommand(docId));
}, [dispatch]); }, [dispatch, docId]);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
@ -42,17 +51,21 @@ export function useBlockSlash() {
onClose, onClose,
blockId, blockId,
searchText, searchText,
hoverOption,
}; };
} }
export function useSubscribeSlash() {
const slashCommandState = useAppSelector((state) => state.documentSlashCommand);
const visible = useMemo(() => slashCommandState.isSlashCommand, [slashCommandState.isSlashCommand]); export function useSubscribeSlash() {
const blockId = useMemo(() => slashCommandState.blockId, [slashCommandState.blockId]); const slashCommandState = useSubscribeSlashState();
const visible = slashCommandState.isSlashCommand;
const blockId = slashCommandState.blockId;
const { node } = useSubscribeNode(blockId || ''); const { node } = useSubscribeNode(blockId || '');
const slashText = useMemo(() => { const slashText = useMemo(() => {
if (!node) return ''; if (!node) return '';
const delta = node.data.delta || []; const delta = node.data.delta || [];
return delta return delta
.map((op: Op) => { .map((op: Op) => {
if (typeof op.insert === 'string') { if (typeof op.insert === 'string') {
@ -68,5 +81,6 @@ export function useSubscribeSlash() {
visible, visible,
blockId, blockId,
slashText, slashText,
hoverOption: slashCommandState.hoverOption,
}; };
} }

View File

@ -1,10 +1,12 @@
import React from 'react'; import React, { useEffect } from 'react';
import Popover from '@mui/material/Popover'; import Popover from '@mui/material/Popover';
import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu'; import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu';
import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks'; import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
import { Keyboard } from '$app/constants/document/keyboard';
function BlockSlash({ container }: { container: HTMLDivElement }) {
const { blockId, open, onClose, anchorEl, searchText, hoverOption } = useBlockSlash();
function BlockSlash() {
const { blockId, open, onClose, anchorEl, searchText } = useBlockSlash();
if (!blockId) return null; if (!blockId) return null;
return ( return (
@ -22,7 +24,13 @@ function BlockSlash() {
disableAutoFocus disableAutoFocus
onClose={onClose} onClose={onClose}
> >
<BlockSlashMenu id={blockId} onClose={onClose} searchText={searchText} /> <BlockSlashMenu
container={container}
hoverOption={hoverOption}
id={blockId}
onClose={onClose}
searchText={searchText}
/>
</Popover> </Popover>
); );
} }

View File

@ -1,15 +1,14 @@
import { useCallback, useContext, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import emojiData, { EmojiMartData, Emoji } from '@emoji-mart/data';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useCalloutBlock(nodeId: string) { export function useCalloutBlock(nodeId: string) {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = useMemo(() => Boolean(anchorEl), [anchorEl]); const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]); const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const closeEmojiSelect = useCallback(() => { const closeEmojiSelect = useCallback(() => {
setAnchorEl(null); setAnchorEl(null);

View File

@ -4,12 +4,12 @@ import FormControl from '@mui/material/FormControl';
import Select, { SelectChangeEvent } from '@mui/material/Select'; import Select, { SelectChangeEvent } from '@mui/material/Select';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { supportLanguage } from '$app/constants/document/code'; import { supportLanguage } from '$app/constants/document/code';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
function SelectLanguage({ id, language }: { id: string; language: string }) { function SelectLanguage({ id, language }: { id: string; language: string }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const onLanguageSelect = useCallback( const onLanguageSelect = useCallback(
(event: SelectChangeEvent) => { (event: SelectChangeEvent) => {

View File

@ -1,14 +1,14 @@
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { useCallback, useContext, useMemo } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { Keyboard } from '$app/constants/document/keyboard'; import { Keyboard } from '$app/constants/document/keyboard';
import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents'; import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents';
import { enterActionForBlockThunk } from '$app_reducers/document/async-actions'; import { enterActionForBlockThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useKeyDown(id: string) { export function useKeyDown(id: string) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const commonKeyEvents = useCommonKeyEvents(id); const commonKeyEvents = useCommonKeyEvents(id);
const customEvents = useMemo(() => { const customEvents = useMemo(() => {

View File

@ -1,11 +1,15 @@
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { BlockType, NestedBlock } from '$app/interfaces/document'; import { BlockType, NestedBlock } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlock>) { export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlock>) {
const { docId } = useSubscribeDocument();
// Find the last index of the previous blocks // Find the last index of the previous blocks
const prevNumberedIndex = useAppSelector((state) => { const prevNumberedIndex = useAppSelector((state) => {
const nodes = state['document'].nodes; const documentState = state['document'][docId];
const children = state['document'].children; const nodes = documentState.nodes;
const children = documentState.children;
// The parent must be existed // The parent must be existed
const parent = nodes[node.parent!]; const parent = nodes[node.parent!];
const siblings = children[parent.children]; const siblings = children[parent.children];

View File

@ -17,7 +17,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
<BlockSideToolbar container={container} /> <BlockSideToolbar container={container} />
<TextActionMenu container={container} /> <TextActionMenu container={container} />
<BlockSelection container={container} /> <BlockSelection container={container} />
<BlockSlash /> <BlockSlash container={container} />
<LinkEditPopover /> <LinkEditPopover />
</> </>
); );

View File

@ -1,12 +1,9 @@
import { DocumentData } from '$app/interfaces/document'; import { DocumentData } from '$app/interfaces/document';
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
import { useParseTree } from './Tree.hooks';
export function useRoot({ documentData }: { documentData: DocumentData }) { export function useRoot({ documentData }: { documentData: DocumentData }) {
const { rootId } = documentData; const { rootId } = documentData;
useParseTree(documentData);
const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId); const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId);
return { return {

View File

@ -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]);
}

View File

@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { calcToolbarPosition } from '$app/utils/document/toolbar'; import { calcToolbarPosition } from '$app/utils/document/toolbar';
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { getNode } from '$app/utils/document/node'; import { getNode } from '$app/utils/document/node';
import { debounce } from '$app/utils/tool'; import { debounce } from '$app/utils/tool';
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
export function useMenuStyle(container: HTMLDivElement) { export function useMenuStyle(container: HTMLDivElement) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const id = useAppSelector((state) => state.documentRange.caret?.id);
const caret = useSubscribeCaret();
const id = caret?.id;
const [isScrolling, setIsScrolling] = useState(false); const [isScrolling, setIsScrolling] = useState(false);
const reCalculatePosition = useCallback(() => { const reCalculatePosition = useCallback(() => {

View File

@ -1,8 +1,8 @@
import { useMenuStyle } from './index.hooks'; import { useMenuStyle } from './index.hooks';
import { useAppSelector } from '$app/stores/store';
import TextActionMenuList from '$app/components/document/TextActionMenu/menu'; import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
import BlockPortal from '$app/components/document/BlockPortal'; import BlockPortal from '$app/components/document/BlockPortal';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => { const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
const { ref, id } = useMenuStyle(container); const { ref, id } = useMenuStyle(container);
@ -28,7 +28,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
); );
}; };
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => { const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const range = useAppSelector((state) => state.documentRange); const range = useSubscribeRanges();
const canShow = useMemo(() => { const canShow = useMemo(() => {
const { isDragging, focus, anchor, ranges, caret } = range; const { isDragging, focus, anchor, ranges, caret } = range;
// don't show if dragging // don't show if dragging

View File

@ -8,12 +8,13 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { newLinkThunk } from '$app_reducers/document/async-actions/link'; import { newLinkThunk } from '$app_reducers/document/async-actions/link';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => { const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const focusId = useAppSelector((state) => state.documentRange.focus?.id || ''); const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
const { node: focusNode } = useSubscribeNode(focusId); const { node: focusNode } = useSubscribeNode(focusId);
const [isActive, setIsActive] = React.useState(false); const [isActive, setIsActive] = React.useState(false);
@ -33,9 +34,14 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
const isFormatActive = useCallback(async () => { const isFormatActive = useCallback(async () => {
if (!focusNode) return false; if (!focusNode) return false;
const { payload: isActive } = await dispatch(getFormatActiveThunk(format)); const { payload: isActive } = await dispatch(
getFormatActiveThunk({
format,
docId,
})
);
return !!isActive; return !!isActive;
}, [dispatch, format, focusNode]); }, [docId, dispatch, format, focusNode]);
const toggleFormat = useCallback( const toggleFormat = useCallback(
async (format: TextAction) => { async (format: TextAction) => {
@ -52,8 +58,12 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
); );
const addLink = useCallback(() => { const addLink = useCallback(() => {
dispatch(newLinkThunk()); dispatch(
}, [dispatch]); newLinkThunk({
docId,
})
);
}, [dispatch, docId]);
const formatClick = useCallback( const formatClick = useCallback(
(format: TextAction) => { (format: TextAction) => {

View File

@ -1,4 +1,3 @@
import { useAppSelector } from '$app/stores/store';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
blockConfig, blockConfig,
@ -9,9 +8,10 @@ import {
} from '$app/constants/document/config'; } from '$app/constants/document/config';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { TextAction } from '$app/interfaces/document'; import { TextAction } from '$app/interfaces/document';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
export function useTextActionMenu() { export function useTextActionMenu() {
const range = useAppSelector((state) => state.documentRange); const range = useSubscribeRanges();
const isSingleLine = useMemo(() => { const isSingleLine = useMemo(() => {
return range.focus?.id === range.anchor?.id; return range.focus?.id === range.anchor?.id;
}, [range]); }, [range]);

View File

@ -10,9 +10,10 @@ import {
} from '$app_reducers/document/async-actions'; } from '$app_reducers/document/async-actions';
import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents'; import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents'; import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useKeyDown(id: string) { export function useKeyDown(id: string) {
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const turnIntoEvents = useTurnIntoBlockEvents(id); const turnIntoEvents = useTurnIntoBlockEvents(id);
const commonKeyEvents = useCommonKeyEvents(id); const commonKeyEvents = useCommonKeyEvents(id);

View File

@ -12,29 +12,30 @@ import isHotkey from 'is-hotkey';
import { slashCommandActions } from '$app_reducers/document/slice'; import { slashCommandActions } from '$app_reducers/document/slice';
import { Keyboard } from '$app/constants/document/keyboard'; import { Keyboard } from '$app/constants/document/keyboard';
import { getDeltaText } from '$app/utils/document/delta'; import { getDeltaText } from '$app/utils/document/delta';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useTurnIntoBlockEvents(id: string) { export function useTurnIntoBlockEvents(id: string) {
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const rangeRef = useRangeRef(); const rangeRef = useRangeRef();
const getFlag = useCallback(() => { const getFlag = useCallback(() => {
const range = rangeRef.current?.caret; const range = rangeRef.current?.caret;
if (!range || range.id !== id) return; if (!range || range.id !== id) return;
const node = getBlock(id); const node = getBlock(docId, id);
const delta = new Delta(node.data.delta || []); const delta = new Delta(node.data.delta || []);
const flag = getDeltaText(delta.slice(0, range.index)); return getDeltaText(delta.slice(0, range.index));
return flag; }, [docId, id, rangeRef]);
}, [id, rangeRef]);
const getDeltaContent = useCallback(() => { const getDeltaContent = useCallback(() => {
const range = rangeRef.current?.caret; const range = rangeRef.current?.caret;
if (!range || range.id !== id) return; if (!range || range.id !== id) return;
const node = getBlock(id); const node = getBlock(docId, id);
const delta = new Delta(node.data.delta || []); const delta = new Delta(node.data.delta || []);
const content = delta.slice(range.index); const content = delta.slice(range.index);
return new Delta(content); return new Delta(content);
}, [id, rangeRef]); }, [docId, id, rangeRef]);
const canHandle = useCallback( const canHandle = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => { (event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
@ -171,17 +172,18 @@ export function useTurnIntoBlockEvents(id: string) {
const flag = getFlag(); const flag = getFlag();
return isHotkey('/', e) && flag === ''; return isHotkey('/', e) && flag === '';
}, },
handler: (e: React.KeyboardEvent<HTMLDivElement>) => { handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
if (!controller) return; if (!controller) return;
dispatch( dispatch(
slashCommandActions.openSlashCommand({ slashCommandActions.openSlashCommand({
blockId: id, blockId: id,
docId,
}) })
); );
}, },
}, },
]; ];
}, [canHandle, controller, dispatch, getDeltaContent, getFlag, id, spaceTriggerMap]); }, [canHandle, controller, dispatch, docId, getDeltaContent, getFlag, id, spaceTriggerMap]);
return turnIntoBlockEvents; return turnIntoBlockEvents;
} }

View File

@ -1,13 +1,13 @@
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react'; import { useCallback } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update'; import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
import { BlockData, BlockType } from '$app/interfaces/document'; import { BlockData, BlockType } from '$app/interfaces/document';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) { export function useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const toggleCheckbox = useCallback(() => { const toggleCheckbox = useCallback(() => {
if (!controller) return; if (!controller) return;
void dispatch( void dispatch(

View File

@ -1,13 +1,13 @@
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react'; import { useCallback } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update'; import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
import { BlockData, BlockType } from '$app/interfaces/document'; import { BlockData, BlockType } from '$app/interfaces/document';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) { export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const toggleCollapsed = useCallback(() => { const toggleCollapsed = useCallback(() => {
if (!controller) return; if (!controller) return;
void dispatch( void dispatch(

View File

@ -1,16 +1,16 @@
import { useCallback, useContext, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { copyThunk } from '$app_reducers/document/async-actions/copyPaste'; import { copyThunk } from '$app_reducers/document/async-actions/copy_paste';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { BlockCopyData } from '$app/interfaces/document'; import { BlockCopyData } from '$app/interfaces/document';
import { clipboardTypes } from '$app/constants/document/copy_paste'; import { clipboardTypes } from '$app/constants/document/copy_paste';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useCopy(container: HTMLDivElement) { export function useCopy(container: HTMLDivElement) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const handleCopyCapture = useCallback( const onCopy = useCallback(
(e: ClipboardEvent) => { (e: ClipboardEvent, isCut: boolean) => {
if (!controller) return; if (!controller) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -22,16 +22,35 @@ export function useCopy(container: HTMLDivElement) {
dispatch( dispatch(
copyThunk({ copyThunk({
setClipboardData, setClipboardData,
controller,
isCut,
}) })
); );
}, },
[controller, dispatch] [controller, dispatch]
); );
const handleCopyCapture = useCallback(
(e: ClipboardEvent) => {
onCopy(e, false);
},
[onCopy]
);
const handleCutCapture = useCallback(
(e: ClipboardEvent) => {
onCopy(e, true);
},
[onCopy]
);
useEffect(() => { useEffect(() => {
container.addEventListener('copy', handleCopyCapture, true); container.addEventListener('copy', handleCopyCapture, true);
container.addEventListener('cut', handleCutCapture, true);
return () => { return () => {
container.removeEventListener('copy', handleCopyCapture, true); container.removeEventListener('copy', handleCopyCapture, true);
container.removeEventListener('cut', handleCutCapture, true);
}; };
}, [container, handleCopyCapture]); }, [container, handleCopyCapture, handleCutCapture]);
} }

View File

@ -1,13 +1,12 @@
import { useCallback, useContext, useEffect } from 'react'; import { useCallback, useContext, useEffect } from 'react';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { pasteThunk } from '$app_reducers/document/async-actions/copy_paste';
import { pasteThunk } from '$app_reducers/document/async-actions/copyPaste';
import { clipboardTypes } from '$app/constants/document/copy_paste'; import { clipboardTypes } from '$app/constants/document/copy_paste';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function usePaste(container: HTMLDivElement) { export function usePaste(container: HTMLDivElement) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const handlePasteCapture = useCallback( const handlePasteCapture = useCallback(
(e: ClipboardEvent) => { (e: ClipboardEvent) => {
if (!controller) return; if (!controller) return;

View File

@ -6,16 +6,19 @@ import {
rightActionForBlockThunk, rightActionForBlockThunk,
upDownActionForBlockThunk, upDownActionForBlockThunk,
} from '$app_reducers/document/async-actions'; } from '$app_reducers/document/async-actions';
import { useContext, useMemo } from 'react'; import { useMemo } from 'react';
import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks'; import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import Delta from 'quill-delta';
export function useCommonKeyEvents(id: string) { export function useCommonKeyEvents(id: string) {
const { focused, caretRef } = useFocused(id); const { focused, caretRef } = useFocused(id);
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const commonKeyEvents = useMemo(() => { const commonKeyEvents = useMemo(() => {
return [ return [
@ -42,7 +45,7 @@ export function useCommonKeyEvents(id: string) {
}, },
handler: (e: React.KeyboardEvent<HTMLDivElement>) => { handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
dispatch(upDownActionForBlockThunk({ id })); dispatch(upDownActionForBlockThunk({ docId, id }));
}, },
}, },
{ {
@ -52,27 +55,29 @@ export function useCommonKeyEvents(id: string) {
}, },
handler: (e: React.KeyboardEvent<HTMLDivElement>) => { handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
dispatch(upDownActionForBlockThunk({ id, down: true })); dispatch(upDownActionForBlockThunk({ docId, id, down: true }));
}, },
}, },
{ {
// handle left arrow key and no other key is pressed // handle left arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.LEFT, e); return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
}, },
handler: (e: React.KeyboardEvent<HTMLDivElement>) => { handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
dispatch(leftActionForBlockThunk({ id })); dispatch(leftActionForBlockThunk({ docId, id }));
}, },
}, },
{ {
// handle right arrow key and no other key is pressed // handle right arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.RIGHT, e); const block = getBlock(docId, id);
const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
}, },
handler: (e: React.KeyboardEvent<HTMLDivElement>) => { handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
dispatch(rightActionForBlockThunk({ id })); dispatch(rightActionForBlockThunk({ docId, id }));
}, },
}, },
{ {
@ -91,6 +96,6 @@ export function useCommonKeyEvents(id: string) {
}, },
}, },
]; ];
}, [caretRef, controller, dispatch, focused, id]); }, [docId, caretRef, controller, dispatch, focused, id]);
return commonKeyEvents; return commonKeyEvents;
} }

View File

@ -1,12 +1,12 @@
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions'; import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) { export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const penddingRef = useRef(false); const penddingRef = useRef(false);
const { node } = useSubscribeNode(id); const { node } = useSubscribeNode(id);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { RangeStatic } from 'quill'; import { RangeStatic } from 'quill';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { rangeActions } from '$app_reducers/document/slice'; import { rangeActions } from '$app_reducers/document/slice';
@ -8,6 +8,7 @@ import {
useSubscribeDecorate, useSubscribeDecorate,
} from '$app/components/document/_shared/SubscribeSelection.hooks'; } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { storeRangeThunk } from '$app_reducers/document/async-actions/range'; import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useSelection(id: string) { export function useSelection(id: string) {
const rangeRef = useRangeRef(); const rangeRef = useRangeRef();
@ -15,12 +16,13 @@ export function useSelection(id: string) {
const decorateProps = useSubscribeDecorate(id); const decorateProps = useSubscribeDecorate(id);
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined); const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { docId } = useSubscribeDocument();
const storeRange = useCallback( const storeRange = useCallback(
(range: RangeStatic) => { (range: RangeStatic) => {
dispatch(storeRangeThunk({ id, range })); dispatch(storeRangeThunk({ id, range, docId }));
}, },
[id, dispatch] [docId, id, dispatch]
); );
const onSelectionChange = useCallback( const onSelectionChange = useCallback(
@ -28,14 +30,17 @@ export function useSelection(id: string) {
if (!range) return; if (!range) return;
dispatch( dispatch(
rangeActions.setCaret({ rangeActions.setCaret({
id, docId,
index: range.index, caret: {
length: range.length, id,
index: range.index,
length: range.length,
},
}) })
); );
storeRange(range); storeRange(range);
}, },
[id, dispatch, storeRange] [docId, id, dispatch, storeRange]
); );
useEffect(() => { useEffect(() => {
@ -44,6 +49,7 @@ export function useSelection(id: string) {
setSelection(undefined); setSelection(undefined);
return; return;
} }
setSelection({ setSelection({
index: focusCaret.index, index: focusCaret.index,
length: focusCaret.length, length: focusCaret.length,

View File

@ -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;

View File

@ -9,7 +9,7 @@ import {
indent, indent,
outdent, outdent,
} from '$app/utils/document/slate_editor'; } from '$app/utils/document/slate_editor';
import { focusNodeByIndex, getWordIndices } from '$app/utils/document/node'; import { focusNodeByIndex } from '$app/utils/document/node';
import { Keyboard } from '$app/constants/document/keyboard'; import { Keyboard } from '$app/constants/document/keyboard';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
@ -139,39 +139,6 @@ export function useEditor({
[editor] [editor]
); );
// This is a hack to fix the bug that the editor decoration is updated cause selection is lost
const onMouseDownCapture = useCallback(
(event: React.MouseEvent) => {
editor.deselect();
requestAnimationFrame(() => {
const range = document.caretRangeFromPoint(event.clientX, event.clientY);
if (!range) return;
const selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
selection.addRange(range);
});
},
[editor]
);
// double click to select a word
// This is a hack to fix the bug that mouse down event deselect the selection
const onDoubleClick = useCallback((event: React.MouseEvent) => {
const selection = window.getSelection();
if (!selection) return;
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range) return;
const node = range.startContainer;
const offset = range.startOffset;
const wordIndices = getWordIndices(node, offset);
if (wordIndices.length === 0) return;
range.setStart(node, wordIndices[0].startIndex);
range.setEnd(node, wordIndices[0].endIndex);
selection.removeAllRanges();
selection.addRange(range);
}, []);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
const isFocused = ReactEditor.isFocused(editor); const isFocused = ReactEditor.isFocused(editor);
@ -179,12 +146,25 @@ export function useEditor({
isFocused && editor.deselect(); isFocused && editor.deselect();
return; return;
} }
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children); const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
if (!slateSelection) return; if (!slateSelection) return;
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return; if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
// why we didn't use slate api to change selection?
// because the slate must be focused before change selection,
// but then it will trigger selection change, and the selection is not what we want
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length); const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
if (!isSuccess) { if (!isSuccess) {
Transforms.select(editor, slateSelection); Transforms.select(editor, slateSelection);
} else {
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
requestAnimationFrame(() => {
if (window.getSelection()?.type === 'None' && !editor.selection) {
Transforms.select(editor, slateSelection);
}
});
} }
}, [editor, selection]); }, [editor, selection]);
@ -197,7 +177,5 @@ export function useEditor({
ref, ref,
onKeyDown: onKeyDownRewrite, onKeyDown: onKeyDownRewrite,
onBlur, onBlur,
onMouseDownCapture,
onDoubleClick,
}; };
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,22 +1,30 @@
import { store, useAppSelector } from '@/appflowy_app/stores/store'; import { store, useAppSelector } from '@/appflowy_app/stores/store';
import { createContext, useEffect, useMemo, useRef } from 'react'; import { createContext, useMemo } from 'react';
import { Node } from '$app/interfaces/document'; import { Node } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
/** /**
* Subscribe node information * Subscribe node information
* @param id * @param id
*/ */
export function useSubscribeNode(id: string) { export function useSubscribeNode(id: string) {
const node = useAppSelector<Node>((state) => state.document.nodes[id]); const { docId } = useSubscribeDocument();
const node = useAppSelector<Node>((state) => {
const documentState = state.document[docId];
return documentState?.nodes[id];
});
const childIds = useAppSelector<string[] | undefined>((state) => { const childIds = useAppSelector<string[] | undefined>((state) => {
const childrenId = state.document.nodes[id]?.children; const documentState = state.document[docId];
if (!documentState) return;
const childrenId = documentState.nodes[id]?.children;
if (!childrenId) return; if (!childrenId) return;
return state.document.children[childrenId]; return documentState.children[childrenId];
}); });
const isSelected = useAppSelector<boolean>((state) => { const isSelected = useAppSelector<boolean>((state) => {
return state.documentRectSelection.selection.includes(id) || false; return state.documentRectSelection[docId]?.selection.includes(id) || false;
}); });
// Memoize the node and its children // Memoize the node and its children
@ -32,8 +40,8 @@ export function useSubscribeNode(id: string) {
}; };
} }
export function getBlock(id: string) { export function getBlock(docId: string, id: string) {
return store.getState().document.nodes[id]; return store.getState().document[docId].nodes[id];
} }
export const NodeIdContext = createContext<string>(''); export const NodeIdContext = createContext<string>('');

View File

@ -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;
}

View File

@ -1,15 +1,18 @@
import { useAppSelector } from '$app/stores/store'; import { useAppSelector } from '$app/stores/store';
import { RangeState, RangeStatic } from '$app/interfaces/document'; import { RangeState, RangeStatic } from '$app/interfaces/document';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useSubscribeDecorate(id: string) { export function useSubscribeDecorate(id: string) {
const { docId } = useSubscribeDocument();
const decorateSelection = useAppSelector((state) => { const decorateSelection = useAppSelector((state) => {
return state.documentRange.ranges[id]; return state.documentRange[docId]?.ranges[id];
}); });
const linkDecorateSelection = useAppSelector((state) => { const linkDecorateSelection = useAppSelector((state) => {
const linkPopoverState = state.documentLinkPopover; const linkPopoverState = state.documentLinkPopover[docId];
if (!linkPopoverState.open || linkPopoverState.id !== id) return; if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
return { return {
selection: linkPopoverState.selection, selection: linkPopoverState.selection,
placeholder: linkPopoverState.title, placeholder: linkPopoverState.title,
@ -22,9 +25,11 @@ export function useSubscribeDecorate(id: string) {
}; };
} }
export function useFocused(id: string) { export function useFocused(id: string) {
const { docId } = useSubscribeDocument();
const caretRef = useRef<RangeStatic>(); const caretRef = useRef<RangeStatic>();
const focusCaret = useAppSelector((state) => { const focusCaret = useAppSelector((state) => {
const currentCaret = state.documentRange.caret; const currentCaret = state.documentRange[docId]?.caret;
caretRef.current = currentCaret; caretRef.current = currentCaret;
if (currentCaret?.id === id) { if (currentCaret?.id === id) {
return currentCaret; return currentCaret;
@ -44,10 +49,32 @@ export function useFocused(id: string) {
} }
export function useRangeRef() { export function useRangeRef() {
const { docId, controller } = useSubscribeDocument();
const rangeRef = useRef<RangeState>(); const rangeRef = useRef<RangeState>();
useAppSelector((state) => { useAppSelector((state) => {
const currentRange = state.documentRange; const currentRange = state.documentRange[docId];
rangeRef.current = currentRange; rangeRef.current = currentRange;
}); });
return rangeRef; return rangeRef;
} }
export function useSubscribeRanges() {
const { docId } = useSubscribeDocument();
const rangeState = useAppSelector((state) => {
return state.documentRange[docId];
});
return rangeState;
}
export function useSubscribeCaret() {
const { docId } = useSubscribeDocument();
const caret = useAppSelector((state) => {
return state.documentRange[docId]?.caret;
});
return caret;
}

View File

@ -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;
}

View File

@ -5,20 +5,21 @@ import { DeleteOutline, Done } from '@mui/icons-material';
import EditLink from '$app/components/document/_shared/TextLink/EditLink'; import EditLink from '$app/components/document/_shared/TextLink/EditLink';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice'; import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateLinkThunk } from '$app_reducers/document/async-actions';
import { formatLinkThunk } from '$app_reducers/document/async-actions/link'; import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
import LinkButton from '$app/components/document/_shared/TextLink/LinkButton'; import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeLinkPopover } from '$app/components/document/_shared/SubscribeLinkPopover.hooks';
function LinkEditPopover() { function LinkEditPopover() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { docId, controller } = useSubscribeDocument();
const popoverState = useAppSelector((state) => state.documentLinkPopover);
const popoverState = useSubscribeLinkPopover();
const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState; const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
const onClose = useCallback(() => { const onClose = useCallback(() => {
dispatch(linkPopoverActions.closeLinkPopover()); dispatch(linkPopoverActions.closeLinkPopover(docId));
}, [dispatch]); }, [dispatch, docId]);
const onExited = useCallback(() => { const onExited = useCallback(() => {
if (!id || !selection) return; if (!id || !selection) return;
@ -28,31 +29,39 @@ function LinkEditPopover() {
}; };
dispatch( dispatch(
rangeActions.setRange({ rangeActions.setRange({
docId,
id, id,
rangeStatic: newSelection, rangeStatic: newSelection,
}) })
); );
dispatch( dispatch(
rangeActions.setCaret({ rangeActions.setCaret({
id, docId,
...newSelection, caret: {
id,
...newSelection,
},
}) })
); );
}, [id, selection, title, dispatch]); }, [docId, id, selection, title, dispatch]);
const onChange = useCallback( const onChange = useCallback(
(newVal: { href?: string; title: string }) => { (newVal: { href?: string; title: string }) => {
if (!id) return; if (!id) return;
if (newVal.title === title && newVal.href === href) return; if (newVal.title === title && newVal.href === href) return;
dispatch( dispatch(
updateLinkThunk({ linkPopoverActions.updateLinkPopover({
id, docId,
href: newVal.href, linkState: {
title: newVal.title, id,
href: newVal.href,
title: newVal.title,
},
}) })
); );
}, },
[dispatch, href, id, title] [docId, dispatch, href, id, title]
); );
const onDone = useCallback(async () => { const onDone = useCallback(async () => {

View File

@ -4,6 +4,7 @@ import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.
import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar'; import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { linkPopoverActions } from '$app_reducers/document/slice'; import { linkPopoverActions } from '$app_reducers/document/slice';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
function TextLink({ function TextLink({
getSelection, getSelection,
@ -22,6 +23,7 @@ function TextLink({
const blockId = useContext(NodeIdContext); const blockId = useContext(NodeIdContext);
const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId); const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { docId } = useSubscribeDocument();
const onEdit = useCallback(() => { const onEdit = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
@ -31,18 +33,21 @@ function TextLink({
if (!rect) return; if (!rect) return;
dispatch( dispatch(
linkPopoverActions.setLinkPopover({ linkPopoverActions.setLinkPopover({
anchorPosition: { docId,
top: rect.top + rect.height, linkState: {
left: rect.left + rect.width / 2, anchorPosition: {
top: rect.top + rect.height,
left: rect.left + rect.width / 2,
},
id: blockId,
selection,
title,
href,
open: true,
}, },
id: blockId,
selection,
title,
href,
open: true,
}) })
); );
}, [blockId, dispatch, getSelection, href, ref, title]); }, [blockId, dispatch, docId, getSelection, href, ref, title]);
if (!blockId) return null; if (!blockId) return null;
return ( return (

View File

@ -1,14 +1,14 @@
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react'; import { useCallback } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document'; import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
import { turnToBlockThunk } from '$app_reducers/document/async-actions'; import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) { export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const turnIntoBlock = useCallback( const turnIntoBlock = useCallback(
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => { async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { BlockType } from '$app/interfaces/document'; import { BlockType, SlashCommandOptionKey } from '$app/interfaces/document';
import { import {
ArrowRight, ArrowRight,
@ -14,10 +14,20 @@ import {
Functions, Functions,
} from '@mui/icons-material'; } from '@mui/icons-material';
import Popover, { PopoverProps } from '@mui/material/Popover'; import Popover, { PopoverProps } from '@mui/material/Popover';
import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks'; import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks';
import { Keyboard } from '$app/constants/document/keyboard';
import MenuItem from '$app/components/document/_shared/MenuItem';
import { selectOptionByUpDown } from '$app/utils/document/menu';
interface Option {
key: SlashCommandOptionKey;
type: BlockType;
title: string;
icon: React.ReactNode;
selected?: boolean;
onClick?: (type: BlockType, isSelected: boolean) => void;
}
const TurnIntoPopover = ({ const TurnIntoPopover = ({
id, id,
onClose, onClose,
@ -28,21 +38,18 @@ const TurnIntoPopover = ({
} & PopoverProps) => { } & PopoverProps) => {
const { node } = useSubscribeNode(id); const { node } = useSubscribeNode(id);
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose }); const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
const [hovered, setHovered] = React.useState<SlashCommandOptionKey>();
const options: { const options: Option[] = useMemo(
type: BlockType;
title: string;
icon: React.ReactNode;
selected?: boolean;
onClick?: (type: BlockType, isSelected: boolean) => void;
}[] = useMemo(
() => [ () => [
{ {
key: SlashCommandOptionKey.TEXT,
type: BlockType.TextBlock, type: BlockType.TextBlock,
title: 'Text', title: 'Text',
icon: <TextFields />, icon: <TextFields />,
}, },
{ {
key: SlashCommandOptionKey.HEADING_1,
type: BlockType.HeadingBlock, type: BlockType.HeadingBlock,
title: 'Heading 1', title: 'Heading 1',
icon: <Title />, icon: <Title />,
@ -52,6 +59,7 @@ const TurnIntoPopover = ({
}, },
}, },
{ {
key: SlashCommandOptionKey.HEADING_2,
type: BlockType.HeadingBlock, type: BlockType.HeadingBlock,
title: 'Heading 2', title: 'Heading 2',
icon: <Title />, icon: <Title />,
@ -61,6 +69,7 @@ const TurnIntoPopover = ({
}, },
}, },
{ {
key: SlashCommandOptionKey.HEADING_3,
type: BlockType.HeadingBlock, type: BlockType.HeadingBlock,
title: 'Heading 3', title: 'Heading 3',
icon: <Title />, icon: <Title />,
@ -70,36 +79,43 @@ const TurnIntoPopover = ({
}, },
}, },
{ {
key: SlashCommandOptionKey.TODO,
type: BlockType.TodoListBlock, type: BlockType.TodoListBlock,
title: 'To-do list', title: 'To-do list',
icon: <Check />, icon: <Check />,
}, },
{ {
key: SlashCommandOptionKey.BULLET,
type: BlockType.BulletedListBlock, type: BlockType.BulletedListBlock,
title: 'Bulleted list', title: 'Bulleted list',
icon: <FormatListBulleted />, icon: <FormatListBulleted />,
}, },
{ {
key: SlashCommandOptionKey.NUMBER,
type: BlockType.NumberedListBlock, type: BlockType.NumberedListBlock,
title: 'Numbered list', title: 'Numbered list',
icon: <FormatListNumbered />, icon: <FormatListNumbered />,
}, },
{ {
key: SlashCommandOptionKey.TOGGLE,
type: BlockType.ToggleListBlock, type: BlockType.ToggleListBlock,
title: 'Toggle list', title: 'Toggle list',
icon: <ArrowRight />, icon: <ArrowRight />,
}, },
{ {
key: SlashCommandOptionKey.CODE,
type: BlockType.CodeBlock, type: BlockType.CodeBlock,
title: 'Code', title: 'Code',
icon: <DataObject />, icon: <DataObject />,
}, },
{ {
key: SlashCommandOptionKey.QUOTE,
type: BlockType.QuoteBlock, type: BlockType.QuoteBlock,
title: 'Quote', title: 'Quote',
icon: <FormatQuote />, icon: <FormatQuote />,
}, },
{ {
key: SlashCommandOptionKey.CALLOUT,
type: BlockType.CalloutBlock, type: BlockType.CalloutBlock,
title: 'Callout', title: 'Callout',
icon: <Lightbulb />, icon: <Lightbulb />,
@ -113,24 +129,90 @@ const TurnIntoPopover = ({
[node?.data?.level, turnIntoHeading] [node?.data?.level, turnIntoHeading]
); );
const getSelected = useCallback(
(option: Option) => {
return option.type === node.type && option.selected !== false;
},
[node?.type]
);
const onClick = useCallback(
(option: Option) => {
const isSelected = getSelected(option);
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
},
[getSelected, turnIntoBlock]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
const isUp = e.key === Keyboard.keys.UP;
const isDown = e.key === Keyboard.keys.DOWN;
const isEnter = e.key === Keyboard.keys.ENTER;
const isLeft = e.key === Keyboard.keys.LEFT;
if (isLeft) {
onClose?.();
return;
}
if (!isUp && !isDown && !isEnter) return;
if (isEnter) {
const option = options.find((option) => option.key === hovered);
if (option) {
onClick(option);
}
return;
}
const nextKey = selectOptionByUpDown(
isUp,
String(hovered),
options.map((option) => String(option.key))
);
const nextOption = options.find((option) => String(option.key) === nextKey);
setHovered(nextOption?.key);
},
[hovered, onClick, onClose, options]
);
useEffect(() => {
if (props.open) {
document.addEventListener('keydown', onKeyDown, true);
}
return () => {
document.removeEventListener('keydown', onKeyDown, true);
};
}, [onKeyDown, props.open]);
return ( return (
<Popover disableAutoFocus={true} onClose={onClose} {...props}> <Popover disableAutoFocus={true} onClose={onClose} {...props}>
{options.map((option) => { <div className={'min-w-[220px]'}>
const isSelected = option.type === node.type && option.selected !== false; {options.map((option) => {
return ( return (
<MenuItem <MenuItem
className={'w-[100%]'} iconSize={{
key={option.title} width: 20,
onClick={() => height: 20,
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected) }}
} icon={option.icon}
> title={option.title}
<ListItemIcon>{option.icon}</ListItemIcon> isHovered={hovered === option.key}
<ListItemText>{option.title}</ListItemText> extra={getSelected(option) ? <Check /> : null}
<ListItemIcon>{isSelected ? <Check /> : null}</ListItemIcon> className={'w-[100%]'}
</MenuItem> key={option.title}
); onClick={() => onClick(option)}
})} ></MenuItem>
);
})}
</div>
</Popover> </Popover>
); );
}; };

View File

@ -1,10 +1,10 @@
import { useCallback, useContext, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import isHotkey from 'is-hotkey'; import isHotkey from 'is-hotkey';
import { Keyboard } from '@/appflowy_app/constants/document/keyboard'; import { Keyboard } from '@/appflowy_app/constants/document/keyboard';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export function useUndoRedo(container: HTMLDivElement) { export function useUndoRedo(container: HTMLDivElement) {
const controller = useContext(DocumentControllerContext); const { controller } = useSubscribeDocument();
const onUndo = useCallback(() => { const onUndo = useCallback(() => {
if (!controller) return; if (!controller) return;

View File

@ -115,9 +115,39 @@ export interface DocumentState {
// map of block id to children block ids // map of block id to children block ids
children: Record<string, string[]>; children: Record<string, string[]>;
} }
export interface SlashCommandState { export interface SlashCommandState {
isSlashCommand: boolean; isSlashCommand: boolean;
blockId?: string; blockId?: string;
hoverOption?: SlashCommandOption;
}
export enum SlashCommandOptionKey {
TEXT,
PAGE,
TODO,
BULLET,
NUMBER,
TOGGLE,
CODE,
EQUATION,
QUOTE,
CALLOUT,
DIVIDER,
HEADING_1,
HEADING_2,
HEADING_3,
}
export interface SlashCommandOption {
type: BlockType;
data?: BlockData<any>;
key: SlashCommandOptionKey;
}
export enum SlashCommandGroup {
BASIC = 'Basic',
MEDIA = 'Media',
} }
export interface RectSelectionState { export interface RectSelectionState {

View File

@ -1,8 +1,7 @@
import { DocumentBlockJSON, DocumentData, Node } from '@/appflowy_app/interfaces/document'; import { DocumentData, Node } from '@/appflowy_app/interfaces/document';
import { createContext } from 'react'; import { createContext } from 'react';
import { DocumentBackendService } from './document_bd_svc'; import { DocumentBackendService } from './document_bd_svc';
import { import {
FlowyError,
BlockActionPB, BlockActionPB,
DocEventPB, DocEventPB,
BlockActionTypePB, BlockActionTypePB,
@ -17,15 +16,13 @@ import { get } from '@/appflowy_app/utils/tool';
import { blockPB2Node } from '$app/utils/document/block'; import { blockPB2Node } from '$app/utils/document/block';
import { Log } from '$app/utils/log'; import { Log } from '$app/utils/log';
export const DocumentControllerContext = createContext<DocumentController | null>(null);
export class DocumentController { export class DocumentController {
private readonly backendService: DocumentBackendService; private readonly backendService: DocumentBackendService;
private readonly observer: DocumentObserver; private readonly observer: DocumentObserver;
constructor( constructor(
public readonly documentId: string, public readonly documentId: string,
private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void private onDocChange?: (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => void
) { ) {
this.backendService = new DocumentBackendService(documentId); this.backendService = new DocumentBackendService(documentId);
this.observer = new DocumentObserver(documentId); this.observer = new DocumentObserver(documentId);
@ -37,14 +34,17 @@ export class DocumentController {
}); });
const document = await this.backendService.open(); const document = await this.backendService.open();
if (document.ok) { if (document.ok) {
const nodes: DocumentData['nodes'] = {}; const nodes: DocumentData['nodes'] = {};
get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => { get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => {
Object.assign(nodes, { Object.assign(nodes, {
[block.id]: blockPB2Node(block), [block.id]: blockPB2Node(block),
}); });
}); });
const children: Record<string, string[]> = {}; const children: Record<string, string[]> = {};
get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => { get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
children[key] = child.children; children[key] = child.children;
}); });
@ -60,6 +60,7 @@ export class DocumentController {
applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => { applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
Log.debug('applyActions', actions); Log.debug('applyActions', actions);
if (actions.length === 0) return;
await this.backendService.applyActions(actions); await this.backendService.applyActions(actions);
}; };
@ -109,21 +110,25 @@ export class DocumentController {
canUndo = async () => { canUndo = async () => {
const result = await this.backendService.canUndoRedo(); const result = await this.backendService.canUndoRedo();
return result.ok && result.val.can_undo; return result.ok && result.val.can_undo;
}; };
canRedo = async () => { canRedo = async () => {
const result = await this.backendService.canUndoRedo(); const result = await this.backendService.canUndoRedo();
return result.ok && result.val.can_redo; return result.ok && result.val.can_redo;
}; };
undo = async () => { undo = async () => {
const result = await this.backendService.undo(); const result = await this.backendService.undo();
return result.ok && result.val.is_success; return result.ok && result.val.is_success;
}; };
redo = async () => { redo = async () => {
const result = await this.backendService.redo(); const result = await this.backendService.redo();
return result.ok && result.val.is_success; return result.ok && result.val.is_success;
}; };
@ -152,14 +157,17 @@ export class DocumentController {
private composeDelta = (node: Node) => { private composeDelta = (node: Node) => {
const delta = node.data.delta; const delta = node.data.delta;
if (!delta) { if (!delta) {
return; return;
} }
// we use yjs to compose delta, it can make sure the delta is correct // we use yjs to compose delta, it can make sure the delta is correct
// for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }] // for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
// but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }] // but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
const ydoc = new Y.Doc(); const ydoc = new Y.Doc();
const ytext = ydoc.getText(node.id); const ytext = ydoc.getText(node.id);
ytext.applyDelta(delta); ytext.applyDelta(delta);
Object.assign(node.data, { delta: ytext.toDelta() }); Object.assign(node.data, { delta: ytext.toDelta() });
}; };
@ -172,6 +180,7 @@ export class DocumentController {
events.forEach((blockEvent) => { events.forEach((blockEvent) => {
blockEvent.event.forEach((_payload) => { blockEvent.event.forEach((_payload) => {
this.onDocChange?.({ this.onDocChange?.({
docId: this.documentId,
isRemote: is_remote, isRemote: is_remote,
data: _payload, data: _payload,
}); });
@ -179,3 +188,5 @@ export class DocumentController {
}); });
}; };
} }
export const DocumentControllerContext = createContext<DocumentController>(new DocumentController(''));

View File

@ -1,15 +1,17 @@
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentState } from '$app/interfaces/document'; import { RootState } from '$app/stores/store';
export const deleteNodeThunk = createAsyncThunk( export const deleteNodeThunk = createAsyncThunk(
'document/deleteNode', 'document/deleteNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = getState() as { document: DocumentState }; const state = getState() as RootState;
const node = state.document.nodes[id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
if (!node) return; if (!node) return;
await controller.applyActions([controller.getDeleteAction(node)]); await controller.applyActions([controller.getDeleteAction(node)]);
} }

View File

@ -1,23 +1,29 @@
import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { newBlock } from '$app/utils/document/block';
import { rectSelectionActions } from '$app_reducers/document/slice'; import { rectSelectionActions } from '$app_reducers/document/slice';
import { getDuplicateActions } from '$app/utils/document/action'; import { getDuplicateActions } from '$app/utils/document/action';
import { RootState } from '$app/stores/store';
export const duplicateBelowNodeThunk = createAsyncThunk( export const duplicateBelowNodeThunk = createAsyncThunk(
'document/duplicateBelowNode', 'document/duplicateBelowNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const state = getState() as { document: DocumentState }; const state = getState() as RootState;
const node = state.document.nodes[id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
if (!node || !node.parent) return; if (!node || !node.parent) return;
const duplicateActions = getDuplicateActions(id, node.parent, state.document, controller); const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
if (!duplicateActions) return; if (!duplicateActions) return;
await controller.applyActions(duplicateActions.actions); await controller.applyActions(duplicateActions.actions);
dispatch(rectSelectionActions.updateSelections([duplicateActions.newNodeId])); dispatch(
rectSelectionActions.updateSelections({
docId,
selection: [duplicateActions.newNodeId],
})
);
} }
); );

View File

@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
import { getPrevNodeId } from '$app/utils/document/block'; import { getPrevNodeId } from '$app/utils/document/block';
import { RootState } from '$app/stores/store';
/** /**
* indent node * indent node
@ -16,24 +17,26 @@ export const indentNodeThunk = createAsyncThunk(
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = getState() as RootState;
const node = state.nodes[id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
if (!node.parent) return; if (!node.parent) return;
// get prev node // get prev node
const prevNodeId = getPrevNodeId(state, id); const prevNodeId = getPrevNodeId(docState, id);
if (!prevNodeId) return; if (!prevNodeId) return;
const newParentNode = state.nodes[prevNodeId]; const newParentNode = docState.nodes[prevNodeId];
// check if prev node is allowed to have children // check if prev node is allowed to have children
const config = blockConfig[newParentNode.type]; const config = blockConfig[newParentNode.type];
if (!config.canAddChild) return; if (!config.canAddChild) return;
// check if prev node has children and get last child for new prev node // check if prev node has children and get last child for new prev node
const newParentChildren = state.children[newParentNode.children]; const newParentChildren = docState.children[newParentNode.children];
const newPrevId = newParentChildren[newParentChildren.length - 1]; const newPrevId = newParentChildren[newParentChildren.length - 1];
const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId); const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
const childrenNodes = state.children[node.children].map((id) => state.nodes[id]); const childrenNodes = docState.children[node.children].map((id) => docState.nodes[id]);
const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id); const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
await controller.applyActions([moveAction, ...moveChildrenActions]); await controller.applyActions([moveAction, ...moveChildrenActions]);

View File

@ -2,6 +2,7 @@ import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { newBlock } from '$app/utils/document/block'; import { newBlock } from '$app/utils/document/block';
import { RootState } from '$app/stores/store';
export const insertAfterNodeThunk = createAsyncThunk( export const insertAfterNodeThunk = createAsyncThunk(
'document/insertAfterNode', 'document/insertAfterNode',
@ -12,10 +13,13 @@ export const insertAfterNodeThunk = createAsyncThunk(
data = { data = {
delta: [], delta: [],
}, },
id,
} = payload; } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = getState() as { document: DocumentState }; const state = getState() as RootState;
const node = state.document.nodes[payload.id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
if (!node) return; if (!node) return;
const parentId = node.parent; const parentId = node.parent;
if (!parentId) return; if (!parentId) return;

View File

@ -4,6 +4,7 @@ import { DocumentState } from '$app/interfaces/document';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
import { getMoveChildrenActions } from '$app/utils/document/action'; import { getMoveChildrenActions } from '$app/utils/document/action';
import { RootState } from '$app/stores/store';
/** /**
* Merge two blocks * Merge two blocks
@ -16,9 +17,11 @@ export const mergeDeltaThunk = createAsyncThunk(
async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => { async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
const { sourceId, targetId, controller } = payload; const { sourceId, targetId, controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = getState() as RootState;
const target = state.nodes[targetId]; const docId = controller.documentId;
const source = state.nodes[sourceId]; const docState = state.document[docId];
const target = docState.nodes[targetId];
const source = docState.nodes[sourceId];
if (!target || !source) return; if (!target || !source) return;
const targetDelta = new Delta(target.data.delta); const targetDelta = new Delta(target.data.delta);
const sourceDelta = new Delta(source.data.delta); const sourceDelta = new Delta(source.data.delta);
@ -34,7 +37,7 @@ export const mergeDeltaThunk = createAsyncThunk(
const actions = [updateAction]; const actions = [updateAction];
// move children // move children
const children = state.children[source.children].map((id) => state.nodes[id]); const children = docState.children[source.children].map((id) => docState.nodes[id]);
const moveActions = getMoveChildrenActions({ const moveActions = getMoveChildrenActions({
controller, controller,
children, children,

View File

@ -1,7 +1,7 @@
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentState } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
import { RootState } from '$app/stores/store';
/** /**
* outdent node * outdent node
@ -17,16 +17,18 @@ export const outdentNodeThunk = createAsyncThunk(
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = getState() as RootState;
const node = state.nodes[id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
const parentId = node.parent; const parentId = node.parent;
if (!parentId) return; if (!parentId) return;
const ancestorId = state.nodes[parentId].parent; const ancestorId = docState.nodes[parentId].parent;
if (!ancestorId) return; if (!ancestorId) return;
const parent = state.nodes[parentId]; const parent = docState.nodes[parentId];
const index = state.children[parent.children].indexOf(id); const index = docState.children[parent.children].indexOf(id);
const nextSiblingIds = state.children[parent.children].slice(index + 1); const nextSiblingIds = docState.children[parent.children].slice(index + 1);
const actions = []; const actions = [];
const moveAction = controller.getMoveAction(node, ancestorId, parentId); const moveAction = controller.getMoveAction(node, ancestorId, parentId);
@ -35,7 +37,7 @@ export const outdentNodeThunk = createAsyncThunk(
const config = blockConfig[node.type]; const config = blockConfig[node.type];
if (nextSiblingIds.length > 0) { if (nextSiblingIds.length > 0) {
if (config.canAddChild) { if (config.canAddChild) {
const children = state.children[node.children]; const children = docState.children[node.children];
let lastChildId: string | null = null; let lastChildId: string | null = null;
const lastIndex = children.length - 1; const lastIndex = children.length - 1;
if (lastIndex >= 0) { if (lastIndex >= 0) {
@ -43,12 +45,12 @@ export const outdentNodeThunk = createAsyncThunk(
} }
const moveChildrenActions = nextSiblingIds const moveChildrenActions = nextSiblingIds
.reverse() .reverse()
.map((id) => controller.getMoveAction(state.nodes[id], node.id, lastChildId)); .map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
actions.push(...moveChildrenActions); actions.push(...moveChildrenActions);
} else { } else {
const moveChildrenActions = nextSiblingIds const moveChildrenActions = nextSiblingIds
.reverse() .reverse()
.map((id) => controller.getMoveAction(state.nodes[id], ancestorId, node.id)); .map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
actions.push(...moveChildrenActions); actions.push(...moveChildrenActions);
} }
} }

View File

@ -2,14 +2,17 @@ import { BlockData, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import Delta, { Op } from 'quill-delta'; import Delta, { Op } from 'quill-delta';
import { RootState } from '$app/stores/store';
export const updateNodeDeltaThunk = createAsyncThunk( export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta', 'document/updateNodeDelta',
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
const { id, delta, controller } = payload; const { id, delta, controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = getState() as RootState;
const node = state.nodes[id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || [])); const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
if (diffDelta.ops.length === 0) return; if (diffDelta.ops.length === 0) return;
@ -34,8 +37,10 @@ export const updateNodeDataThunk = createAsyncThunk<
>('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => { >('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
const { id, data, controller } = payload; const { id, data, controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = getState() as RootState;
const node = state.nodes[id]; const docId = controller.documentId;
const docState = state.document[docId];
const node = docState.nodes[id];
const newData = { ...node.data, ...data }; const newData = { ...node.data, ...data };

View File

@ -17,13 +17,17 @@ import { rangeActions } from '$app_reducers/document/slice';
export const copyThunk = createAsyncThunk< export const copyThunk = createAsyncThunk<
void, void,
{ {
isCut?: boolean;
controller: DocumentController;
setClipboardData: (data: BlockCopyData) => void; setClipboardData: (data: BlockCopyData) => void;
} }
>('document/copy', async (payload, thunkAPI) => { >('document/copy', async (payload, thunkAPI) => {
const { getState } = thunkAPI; const { getState, dispatch } = thunkAPI;
const { setClipboardData } = payload; const { setClipboardData, isCut = false, controller } = payload;
const docId = controller.documentId;
const state = getState() as RootState; const state = getState() as RootState;
const { document, documentRange } = state; const document = state.document[docId];
const documentRange = state.documentRange[docId];
const startAndEndIds = getStartAndEndIdsByRange(documentRange); const startAndEndIds = getStartAndEndIdsByRange(documentRange);
if (startAndEndIds.length === 0) return; if (startAndEndIds.length === 0) return;
const result: DocumentBlockJSON[] = []; const result: DocumentBlockJSON[] = [];
@ -70,6 +74,10 @@ export const copyThunk = createAsyncThunk<
text: '', text: '',
html: '', html: '',
}); });
if (isCut) {
// delete range blocks
await dispatch(deleteRangeAndInsertThunk({ controller }));
}
}); });
/** /**
@ -94,6 +102,11 @@ export const pasteThunk = createAsyncThunk<
// delete range blocks // delete range blocks
await dispatch(deleteRangeAndInsertThunk({ controller })); await dispatch(deleteRangeAndInsertThunk({ controller }));
const state = getState() as RootState;
const docId = controller.documentId;
const document = state.document[docId];
const documentRange = state.documentRange[docId];
let pasteData; let pasteData;
if (data.json) { if (data.json) {
pasteData = JSON.parse(data.json) as DocumentBlockJSON[]; pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
@ -103,7 +116,6 @@ export const pasteThunk = createAsyncThunk<
// TODO: implement html // TODO: implement html
} }
if (!pasteData) return; if (!pasteData) return;
const { document, documentRange } = getState() as RootState;
const { caret } = documentRange; const { caret } = documentRange;
if (!caret) return; if (!caret) return;
const currentBlock = document.nodes[caret.id]; const currentBlock = document.nodes[caret.id];
@ -135,9 +147,12 @@ export const pasteThunk = createAsyncThunk<
// set caret to the end of the last paste block // set caret to the end of the last paste block
dispatch( dispatch(
rangeActions.setCaret({ rangeActions.setCaret({
id: lastPasteBlock.id, docId,
index: new Delta(lastPasteBlock.data.delta).length(), caret: {
length: 0, id: lastPasteBlock.id,
index: new Delta(lastPasteBlock.data.delta).length(),
length: 0,
},
}) })
); );
return; return;
@ -150,7 +165,11 @@ export const pasteThunk = createAsyncThunk<
length: currentBlockDelta.length() - caret.index, length: currentBlockDelta.length() - caret.index,
}); });
let newCaret; let newCaret: {
id: string;
index: number;
length: number;
};
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta); const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta); const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta); let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
@ -198,5 +217,10 @@ export const pasteThunk = createAsyncThunk<
// set caret to the end of the last paste block // set caret to the end of the last paste block
if (!newCaret) return; if (!newCaret) return;
dispatch(rangeActions.setCaret(newCaret)); dispatch(
rangeActions.setCaret({
docId,
caret: newCaret,
})
);
}); });

View File

@ -4,42 +4,53 @@ import { TextAction } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>( export const getFormatActiveThunk = createAsyncThunk<
'document/getFormatActive', boolean,
async (format, thunkAPI) => { {
const { getState } = thunkAPI; format: TextAction;
const state = getState() as RootState; docId: string;
const { document, documentRange } = state;
const { ranges } = documentRange;
const match = (delta: Delta, format: TextAction) => {
return delta.ops.every((op) => op.attributes?.[format]);
};
return Object.entries(ranges).every(([id, range]) => {
const node = document.nodes[id];
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const rangeDelta = delta.slice(index, index + length);
return match(rangeDelta, format);
});
} }
); >('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const document = state.document[docId];
const documentRange = state.documentRange[docId];
const { ranges } = documentRange;
const match = (delta: Delta, format: TextAction) => {
return delta.ops.every((op) => op.attributes?.[format]);
};
return Object.entries(ranges).every(([id, range]) => {
const node = document.nodes[id];
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const rangeDelta = delta.slice(index, index + length);
return match(rangeDelta, format);
});
});
export const toggleFormatThunk = createAsyncThunk( export const toggleFormatThunk = createAsyncThunk(
'document/toggleFormat', 'document/toggleFormat',
async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => { async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const { format, controller } = payload; const { format, controller } = payload;
const docId = controller.documentId;
let isActive = payload.isActive; let isActive = payload.isActive;
if (isActive === undefined) { if (isActive === undefined) {
const { payload: active } = await dispatch(getFormatActiveThunk(format)); const { payload: active } = await dispatch(
getFormatActiveThunk({
format,
docId,
})
);
isActive = !!active; isActive = !!active;
} }
const formatValue = isActive ? undefined : true; const formatValue = isActive ? undefined : true;
const state = getState() as RootState; const state = getState() as RootState;
const { document } = state; const document = state.document[docId];
const { ranges } = state.documentRange; const documentRange = state.documentRange[docId];
const { ranges } = documentRange;
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => { const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
const newOps = delta.ops.map((op) => { const newOps = delta.ops.map((op) => {

View File

@ -2,4 +2,4 @@ export * from './blocks';
export * from './turn_to'; export * from './turn_to';
export * from './keydown'; export * from './keydown';
export * from './range'; export * from './range';
export { updateLinkThunk } from '$app_reducers/document/async-actions/link'; export * from './link';

View File

@ -1,6 +1,6 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document'; import { BlockType, RangeStatic, SplitRelationship } from '$app/interfaces/document';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to'; import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { import {
findNextHasDeltaNode, findNextHasDeltaNode,
@ -29,8 +29,9 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
'document/backspaceDeleteActionForBlock', 'document/backspaceDeleteActionForBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const docId = controller.documentId;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = (getState() as RootState).document[docId];
const node = state.nodes[id]; const node = state.nodes[id];
if (!node.parent) return; if (!node.parent) return;
const parent = state.nodes[node.parent]; const parent = state.nodes[node.parent];
@ -60,8 +61,13 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
controller, controller,
}) })
); );
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch(rangeActions.setCaret(caret)); dispatch(
rangeActions.setCaret({
docId,
caret,
})
);
return; return;
} }
// outdent // outdent
@ -81,11 +87,19 @@ export const enterActionForBlockThunk = createAsyncThunk(
const { id, controller } = payload; const { id, controller } = payload;
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const node = state.document.nodes[id]; const docId = controller.documentId;
const caret = state.documentRange.caret; const documentState = state.document[docId];
const node = documentState.nodes[id];
const caret = state.documentRange[docId]?.caret;
if (!node || !caret || caret.id !== id) return; if (!node || !caret || caret.id !== id) return;
const delta = new Delta(node.data.delta);
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
// If the node is not a text block, turn it to a text block
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
const nodeDelta = delta.slice(0, caret.index);
const nodeDelta = new Delta(node.data.delta).slice(0, caret.index);
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length); const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller); const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
@ -98,11 +112,11 @@ export const enterActionForBlockThunk = createAsyncThunk(
}, },
}; };
const children = state.document.children[node.children]; const children = documentState.children[node.children];
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling; const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
const moveChildrenAction = needMoveChildren const moveChildrenAction = needMoveChildren
? controller.getMoveChildrenAction( ? controller.getMoveChildrenAction(
children.map((id) => state.document.nodes[id]), children.map((id) => documentState.nodes[id]),
insertNodeAction.id, insertNodeAction.id,
'' ''
) )
@ -110,12 +124,15 @@ export const enterActionForBlockThunk = createAsyncThunk(
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction]; const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
await controller.applyActions(actions); await controller.applyActions(actions);
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch( dispatch(
rangeActions.setCaret({ rangeActions.setCaret({
id: insertNodeAction.id, docId,
index: 0, caret: {
length: 0, id: insertNodeAction.id,
index: 0,
length: 0,
},
}) })
); );
} }
@ -131,41 +148,48 @@ export const tabActionForBlockThunk = createAsyncThunk(
export const upDownActionForBlockThunk = createAsyncThunk( export const upDownActionForBlockThunk = createAsyncThunk(
'document/upActionForBlock', 'document/upActionForBlock',
async (payload: { id: string; down?: boolean }, thunkAPI) => { async (payload: { docId: string; id: string; down?: boolean }, thunkAPI) => {
const { id, down } = payload; const { docId, id, down } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const documentState = state.document[docId];
const rangeState = state.documentRange[docId];
const caret = rangeState.caret; const caret = rangeState.caret;
const node = state.document.nodes[id]; const node = documentState.nodes[id];
if (!node || !caret || id !== caret.id) return; if (!node || !caret || id !== caret.id) return;
let newCaret; let newCaret;
if (down) { if (down) {
newCaret = transformToNextLineCaret(state.document, caret); newCaret = transformToNextLineCaret(documentState, caret);
} else { } else {
newCaret = transformToPrevLineCaret(state.document, caret); newCaret = transformToPrevLineCaret(documentState, caret);
} }
if (!newCaret) { if (!newCaret) {
return; return;
} }
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch(rangeActions.setCaret(newCaret)); dispatch(
rangeActions.setCaret({
docId,
caret: newCaret,
})
);
} }
); );
export const leftActionForBlockThunk = createAsyncThunk( export const leftActionForBlockThunk = createAsyncThunk(
'document/leftActionForBlock', 'document/leftActionForBlock',
async (payload: { id: string }, thunkAPI) => { async (payload: { docId: string; id: string }, thunkAPI) => {
const { id } = payload; const { id, docId } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const documentState = state.document[docId];
const rangeState = state.documentRange[docId];
const caret = rangeState.caret; const caret = rangeState.caret;
const node = state.document.nodes[id]; const node = documentState.nodes[id];
if (!node || !caret || id !== caret.id) return; if (!node || !caret || id !== caret.id) return;
let newCaret; let newCaret: RangeStatic;
if (caret.length > 0) { if (caret.length > 0) {
newCaret = { newCaret = {
id, id,
@ -180,7 +204,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
length: 0, length: 0,
}; };
} else { } else {
const prevNode = findPrevHasDeltaNode(state.document, id); const prevNode = findPrevHasDeltaNode(documentState, id);
if (!prevNode) return; if (!prevNode) return;
const prevDelta = new Delta(prevNode.data.delta); const prevDelta = new Delta(prevNode.data.delta);
newCaret = { newCaret = {
@ -194,22 +218,28 @@ export const leftActionForBlockThunk = createAsyncThunk(
if (!newCaret) { if (!newCaret) {
return; return;
} }
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch(rangeActions.setCaret(newCaret)); dispatch(
rangeActions.setCaret({
docId,
caret: newCaret,
})
);
} }
); );
export const rightActionForBlockThunk = createAsyncThunk( export const rightActionForBlockThunk = createAsyncThunk(
'document/rightActionForBlock', 'document/rightActionForBlock',
async (payload: { id: string }, thunkAPI) => { async (payload: { id: string; docId: string }, thunkAPI) => {
const { id } = payload; const { id, docId } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const documentState = state.document[docId];
const rangeState = state.documentRange[docId];
const caret = rangeState.caret; const caret = rangeState.caret;
const node = state.document.nodes[id]; const node = documentState.nodes[id];
if (!node || !caret || id !== caret.id) return; if (!node || !caret || id !== caret.id) return;
let newCaret; let newCaret: RangeStatic;
const delta = new Delta(node.data.delta); const delta = new Delta(node.data.delta);
const deltaLength = delta.length(); const deltaLength = delta.length();
if (caret.length > 0) { if (caret.length > 0) {
@ -227,7 +257,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
length: 0, length: 0,
}; };
} else { } else {
const nextNode = findNextHasDeltaNode(state.document, id); const nextNode = findNextHasDeltaNode(documentState, id);
if (!nextNode) return; if (!nextNode) return;
newCaret = { newCaret = {
id: nextNode.id, id: nextNode.id,
@ -240,9 +270,14 @@ export const rightActionForBlockThunk = createAsyncThunk(
if (!newCaret) { if (!newCaret) {
return; return;
} }
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch(rangeActions.setCaret(newCaret)); dispatch(
rangeActions.setCaret({
caret: newCaret,
docId,
})
);
} }
); );
@ -259,19 +294,22 @@ export const arrowActionForRangeThunk = createAsyncThunk(
async ( async (
payload: { payload: {
key: string; key: string;
docId: string;
}, },
thunkAPI thunkAPI
) => { ) => {
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const { key, docId } = payload;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const documentState = state.document[docId];
const rangeState = state.documentRange[docId];
let caret; let caret;
const leftCaret = getLeftCaretByRange(rangeState); const leftCaret = getLeftCaretByRange(rangeState);
const rightCaret = getRightCaretByRange(rangeState); const rightCaret = getRightCaretByRange(rangeState);
if (!leftCaret || !rightCaret) return; if (!leftCaret || !rightCaret) return;
switch (payload.key) { switch (key) {
case Keyboard.keys.LEFT: case Keyboard.keys.LEFT:
caret = leftCaret; caret = leftCaret;
break; break;
@ -279,14 +317,19 @@ export const arrowActionForRangeThunk = createAsyncThunk(
caret = rightCaret; caret = rightCaret;
break; break;
case Keyboard.keys.UP: case Keyboard.keys.UP:
caret = transformToPrevLineCaret(state.document, leftCaret); caret = transformToPrevLineCaret(documentState, leftCaret);
break; break;
case Keyboard.keys.DOWN: case Keyboard.keys.DOWN:
caret = transformToNextLineCaret(state.document, rightCaret); caret = transformToNextLineCaret(documentState, rightCaret);
break; break;
} }
if (!caret) return; if (!caret) return;
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch(rangeActions.setCaret(caret)); dispatch(
rangeActions.setCaret({
docId,
caret,
})
);
} }
); );

View File

@ -12,13 +12,14 @@ export const formatLinkThunk = createAsyncThunk<
>('document/formatLink', async (payload, thunkAPI) => { >('document/formatLink', async (payload, thunkAPI) => {
const { controller } = payload; const { controller } = payload;
const { getState } = thunkAPI; const { getState } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState; const state = getState() as RootState;
const linkPopover = state.documentLinkPopover; const documentState = state.document[docId];
const linkPopover = state.documentLinkPopover[docId];
if (!linkPopover) return false; if (!linkPopover) return false;
const { selection, id, href, title = '' } = linkPopover; const { selection, id, href, title = '' } = linkPopover;
if (!selection || !id) return false; if (!selection || !id) return false;
const document = state.document; const node = documentState.nodes[id];
const node = document.nodes[id];
const nodeDelta = new Delta(node.data?.delta); const nodeDelta = new Delta(node.data?.delta);
const index = selection.index || 0; const index = selection.index || 0;
const length = selection.length || 0; const length = selection.length || 0;
@ -44,35 +45,22 @@ export const formatLinkThunk = createAsyncThunk<
return true; return true;
}); });
export const updateLinkThunk = createAsyncThunk< export const newLinkThunk = createAsyncThunk<
void, void,
{ {
id: string; docId: string;
href?: string;
title: string;
} }
>('document/updateLink', async (payload, thunkAPI) => { >('document/newLink', async ({ docId }, thunkAPI) => {
const { id, href, title } = payload;
const { dispatch } = thunkAPI;
dispatch(
linkPopoverActions.updateLinkPopover({
id,
href,
title,
})
);
});
export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (payload, thunkAPI) => {
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const { documentRange, document } = getState() as RootState; const state = getState() as RootState;
const documentState = state.document[docId];
const documentRange = state.documentRange[docId];
const { caret } = documentRange; const { caret } = documentRange;
if (!caret) return; if (!caret) return;
const { index, length, id } = caret; const { index, length, id } = caret;
const block = document.nodes[id]; const block = documentState.nodes[id];
const delta = new Delta(block.data.delta).slice(index, index + length); const delta = new Delta(block.data.delta).slice(index, index + length);
const op = delta.ops.find((op) => op.attributes?.href); const op = delta.ops.find((op) => op.attributes?.href);
const href = op?.attributes?.href as string; const href = op?.attributes?.href as string;
@ -83,21 +71,24 @@ export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (pa
if (!domRange) return; if (!domRange) return;
const title = domSelection.toString(); const title = domSelection.toString();
const { top, left, height, width } = domRange.getBoundingClientRect(); const { top, left, height, width } = domRange.getBoundingClientRect();
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
dispatch( dispatch(
linkPopoverActions.setLinkPopover({ linkPopoverActions.setLinkPopover({
anchorPosition: { docId,
top: top + height, linkState: {
left: left + width / 2, anchorPosition: {
top: top + height,
left: left + width / 2,
},
id,
selection: {
index,
length,
},
title,
href,
open: true,
}, },
id,
selection: {
index,
length,
},
title,
href,
open: true,
}) })
); );
}); });

View File

@ -1,5 +1,5 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document'; import { BlockData, BlockType } from '$app/interfaces/document';
import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks'; import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { rangeActions, slashCommandActions } from '$app_reducers/document/slice'; import { rangeActions, slashCommandActions } from '$app_reducers/document/slice';
@ -18,8 +18,9 @@ export const addBlockBelowClickThunk = createAsyncThunk(
'document/addBlockBelowClick', 'document/addBlockBelowClick',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const docId = controller.documentId;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = (getState() as RootState).document[docId];
const node = state.nodes[id]; const node = state.nodes[id];
if (!node) return; if (!node) return;
const delta = (node.data.delta as Op[]) || []; const delta = (node.data.delta as Op[]) || [];
@ -31,15 +32,25 @@ export const addBlockBelowClickThunk = createAsyncThunk(
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } }) insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
); );
if (newBlockId) { if (newBlockId) {
dispatch(rangeActions.setCaret({ id: newBlockId as string, index: 0, length: 0 })); dispatch(
dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string })); rangeActions.setCaret({
docId,
caret: { id: newBlockId as string, index: 0, length: 0 },
})
);
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
} }
return; return;
} }
// if current block is empty, open slash command // if current block is empty, open slash command
dispatch(rangeActions.setCaret({ id, index: 0, length: 0 })); dispatch(
rangeActions.setCaret({
docId,
caret: { id, index: 0, length: 0 },
})
);
dispatch(slashCommandActions.openSlashCommand({ blockId: id })); dispatch(slashCommandActions.openSlashCommand({ docId, blockId: id }));
} }
); );
@ -63,8 +74,9 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
) => { ) => {
const { id, controller, props } = payload; const { id, controller, props } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState; const state = getState() as RootState;
const { document } = state; const document = state.document[docId];
const node = document.nodes[id]; const node = document.nodes[id];
if (!node) return; if (!node) return;
const delta = new Delta(node.data.delta); const delta = new Delta(node.data.delta);
@ -111,6 +123,11 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
); );
const newBlockId = insertNodePayload.payload as string; const newBlockId = insertNodePayload.payload as string;
dispatch(rangeActions.setCaret({ id: newBlockId, index: 0, length: 0 })); dispatch(
rangeActions.setCaret({
docId,
caret: { id: newBlockId, index: 0, length: 0 },
})
);
} }
); );

View File

@ -15,6 +15,7 @@ import { RangeState, SplitRelationship } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
interface storeRangeThunkPayload { interface storeRangeThunkPayload {
docId: string;
id: string; id: string;
range: { range: {
index: number; index: number;
@ -28,10 +29,11 @@ interface storeRangeThunkPayload {
* 2. if isDragging is true, we need amend range between anchor and focus * 2. if isDragging is true, we need amend range between anchor and focus
*/ */
export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => { export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => {
const { id, range } = payload; const { docId, id, range } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const rangeState = state.documentRange[docId];
const documentState = state.document[docId];
// we need amend range between anchor and focus // we need amend range between anchor and focus
const { anchor, focus, isDragging } = rangeState; const { anchor, focus, isDragging } = rangeState;
if (!isDragging || !anchor || !focus) return; if (!isDragging || !anchor || !focus) return;
@ -42,20 +44,30 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
let anchorIndex = anchor.point.index; let anchorIndex = anchor.point.index;
let anchorLength = anchor.point.length; let anchorLength = anchor.point.length;
if (anchorIndex === undefined || anchorLength === undefined) { if (anchorIndex === undefined || anchorLength === undefined) {
dispatch(rangeActions.setAnchorPointRange(range)); dispatch(
rangeActions.setAnchorPointRange({
...range,
docId,
})
);
anchorIndex = range.index; anchorIndex = range.index;
anchorLength = range.length; anchorLength = range.length;
} }
// if anchor and focus are in the same node, we don't need to amend range // if anchor and focus are in the same node, we don't need to amend range
if (anchor.id === id) { if (anchor.id === id) {
dispatch(rangeActions.setRanges(ranges)); dispatch(
rangeActions.setRanges({
ranges,
docId,
})
);
return; return;
} }
// amend anchor range because slatejs will stop update selection when dragging quickly // amend anchor range because slatejs will stop update selection when dragging quickly
const isForward = anchor.point.y < focus.point.y; const isForward = anchor.point.y < focus.point.y;
const anchorDelta = new Delta(state.document.nodes[anchor.id].data.delta); const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
if (isForward) { if (isForward) {
const selectedDelta = anchorDelta.slice(anchorIndex); const selectedDelta = anchorDelta.slice(anchorIndex);
ranges[anchor.id] = { ranges[anchor.id] = {
@ -74,9 +86,9 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
const startId = isForward ? anchor.id : focus.id; const startId = isForward ? anchor.id : focus.id;
const endId = isForward ? focus.id : anchor.id; const endId = isForward ? focus.id : anchor.id;
const middleIds = getMiddleIds(state.document, startId, endId); const middleIds = getMiddleIds(documentState, startId, endId);
middleIds.forEach((id) => { middleIds.forEach((id) => {
const node = state.document.nodes[id]; const node = documentState.nodes[id];
if (!node || !node.data.delta) return; if (!node || !node.data.delta) return;
const delta = new Delta(node.data.delta); const delta = new Delta(node.data.delta);
@ -88,7 +100,12 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
ranges[id] = rangeStatic; ranges[id] = rangeStatic;
}); });
dispatch(rangeActions.setRanges(ranges)); dispatch(
rangeActions.setRanges({
ranges,
docId,
})
);
}); });
/** /**
@ -101,9 +118,11 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
'document/deleteRange', 'document/deleteRange',
async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => { async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
const { controller, insertDelta } = payload; const { controller, insertDelta } = payload;
const docId = controller.documentId;
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const rangeState = state.documentRange[docId];
const documentState = state.document[docId];
const actions = []; const actions = [];
// get merge actions // get merge actions
@ -112,20 +131,25 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
actions.push(...mergeActions); actions.push(...mergeActions);
} }
// get middle nodes // get middle nodes
const middleIds = getMiddleIdsByRange(rangeState, state.document); const middleIds = getMiddleIdsByRange(rangeState, documentState);
// delete middle nodes // delete middle nodes
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || []; const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
actions.push(...deleteMiddleNodesActions); actions.push(...deleteMiddleNodesActions);
const caret = getAfterMergeCaretByRange(rangeState, insertDelta); const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
if (actions.length === 0) return;
// apply actions // apply actions
await controller.applyActions(actions); await controller.applyActions(actions);
// clear range // clear range
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
if (caret) { if (caret) {
dispatch(rangeActions.setCaret(caret)); dispatch(
rangeActions.setCaret({
docId,
caret,
})
);
} }
} }
); );
@ -144,15 +168,17 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => { async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => {
const { controller, shiftKey } = payload; const { controller, shiftKey } = payload;
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState; const state = getState() as RootState;
const rangeState = state.documentRange; const rangeState = state.documentRange[docId];
const documentState = state.document[docId];
const actions = []; const actions = [];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {}; const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
if (!startDelta || !endDelta || !endNode || !startNode) return; if (!startDelta || !endDelta || !endNode || !startNode) return;
// get middle nodes // get middle nodes
const middleIds = getMiddleIds(state.document, startNode.id, endNode.id); const middleIds = getMiddleIds(documentState, startNode.id, endNode.id);
let newStartDelta = new Delta(startDelta); let newStartDelta = new Delta(startDelta);
let caret = null; let caret = null;
@ -174,10 +200,10 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling; blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
if (needMoveChildren) { if (needMoveChildren) {
// filter children by delete middle ids // filter children by delete middle ids
const children = state.document.children[startNode.children].filter((id) => middleIds?.includes(id)); const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
const moveChildrenAction = needMoveChildren const moveChildrenAction = needMoveChildren
? controller.getMoveChildrenAction( ? controller.getMoveChildrenAction(
children.map((id) => state.document.nodes[id]), children.map((id) => documentState.nodes[id]),
insertNodeAction.id, insertNodeAction.id,
'' ''
) )
@ -201,16 +227,21 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
} }
// delete middle nodes // delete middle nodes
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || []; const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
actions.push(...deleteMiddleNodesActions); actions.push(...deleteMiddleNodesActions);
// apply actions // apply actions
await controller.applyActions(actions); await controller.applyActions(actions);
// clear range // clear range
dispatch(rangeActions.clearRange()); dispatch(rangeActions.initialState(docId));
if (caret) { if (caret) {
dispatch(rangeActions.setCaret(caret)); dispatch(
rangeActions.setCaret({
docId,
caret,
})
);
} }
} }
); );

View File

@ -1,15 +1,22 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block'; import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block';
import { DocumentState } from '$app/interfaces/document';
import { rectSelectionActions } from '$app_reducers/document/slice'; import { rectSelectionActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
export const setRectSelectionThunk = createAsyncThunk( export const setRectSelectionThunk = createAsyncThunk(
'document/setRectSelection', 'document/setRectSelection',
async (payload: string[], thunkAPI) => { async (
payload: {
docId: string;
selection: string[];
},
thunkAPI
) => {
const { getState, dispatch } = thunkAPI; const { getState, dispatch } = thunkAPI;
const documentState = (getState() as { document: DocumentState }).document; const { docId, selection } = payload;
const documentState = (getState() as RootState).document[docId];
const selected: Record<string, boolean> = {}; const selected: Record<string, boolean> = {};
payload.forEach((id) => { selection.forEach((id) => {
const node = documentState.nodes[id]; const node = documentState.nodes[id];
if (!node.parent) { if (!node.parent) {
return; return;
@ -18,10 +25,15 @@ export const setRectSelectionThunk = createAsyncThunk(
selected[node.parent] = false; selected[node.parent] = false;
const nextNodeId = getNextNodeId(documentState, node.parent); const nextNodeId = getNextNodeId(documentState, node.parent);
const prevNodeId = getPrevNodeId(documentState, node.parent); const prevNodeId = getPrevNodeId(documentState, node.parent);
if ((nextNodeId && payload.includes(nextNodeId)) || (prevNodeId && payload.includes(prevNodeId))) { if ((nextNodeId && selection.includes(nextNodeId)) || (prevNodeId && selection.includes(prevNodeId))) {
selected[node.parent] = true; selected[node.parent] = true;
} }
}); });
dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id]))); dispatch(
rectSelectionActions.updateSelections({
docId,
selection: selection.filter((id) => selected[id]),
})
);
} }
); );

View File

@ -1,9 +1,10 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document'; import { BlockData, BlockType } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from '$app/constants/document/config';
import { newBlock } from '$app/utils/document/block'; import { newBlock } from '$app/utils/document/block';
import { rangeActions } from '$app_reducers/document/slice'; import { rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
/** /**
* transform to block * transform to block
@ -17,8 +18,9 @@ export const turnToBlockThunk = createAsyncThunk(
'document/turnToBlock', 'document/turnToBlock',
async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => { async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
const { id, controller, type, data } = payload; const { id, controller, type, data } = payload;
const docId = controller.documentId;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = (getState() as RootState).document[docId];
const node = state.nodes[id]; const node = state.nodes[id];
if (!node.parent) return; if (!node.parent) return;
@ -49,7 +51,12 @@ export const turnToBlockThunk = createAsyncThunk(
// submit actions // submit actions
await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]); await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
// set cursor in new block // set cursor in new block
dispatch(rangeActions.setCaret({ id: caretId, index: 0, length: 0 })); dispatch(
rangeActions.setCaret({
docId,
caret: { id: caretId, index: 0, length: 0 },
})
);
} }
); );
@ -64,7 +71,8 @@ export const turnToTextBlockThunk = createAsyncThunk(
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const docId = controller.documentId;
const state = (getState() as RootState).document[docId];
const node = state.nodes[id]; const node = state.nodes[id];
const data = { const data = {
delta: node.data.delta, delta: node.data.delta,

View File

@ -6,31 +6,21 @@ import {
RangeState, RangeState,
RangeStatic, RangeStatic,
LinkPopoverState, LinkPopoverState,
SlashCommandOption,
} from '@/appflowy_app/interfaces/document'; } from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend'; import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { parseValue, matchChange } from '$app/utils/document/subscribe'; import { parseValue, matchChange } from '$app/utils/document/subscribe';
const initialState: DocumentState = { const initialState: Record<string, DocumentState> = {};
nodes: {},
children: {},
};
const rectSelectionInitialState: RectSelectionState = { const rectSelectionInitialState: Record<string, RectSelectionState> = {};
selection: [],
isDragging: false,
};
const rangeInitialState: RangeState = { const rangeInitialState: Record<string, RangeState> = {};
isDragging: false,
ranges: {},
};
const slashCommandInitialState: SlashCommandState = { const slashCommandInitialState: Record<string, SlashCommandState> = {};
isSlashCommand: false,
};
const linkPopoverState: LinkPopoverState = {}; const linkPopoverState: Record<string, LinkPopoverState> = {};
export const documentSlice = createSlice({ export const documentSlice = createSlice({
name: 'document', name: 'document',
@ -39,21 +29,35 @@ export const documentSlice = createSlice({
// Because the document state is updated by the `onDataChange` // Because the document state is updated by the `onDataChange`
reducers: { reducers: {
// initialize the document // initialize the document
clear: () => { initialState: (state, action: PayloadAction<string>) => {
return initialState; const docId = action.payload;
state[docId] = {
nodes: {},
children: {},
};
},
clear: (state, action: PayloadAction<string>) => {
const docId = action.payload;
delete state[docId];
}, },
// set document data // set document data
create: ( create: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
nodes: Record<string, Node>; nodes: Record<string, Node>;
children: Record<string, string[]>; children: Record<string, string[]>;
}> }>
) => { ) => {
const { nodes, children } = action.payload; const { docId, nodes, children } = action.payload;
state.nodes = nodes;
state.children = children; state[docId] = {
nodes,
children,
};
}, },
/** /**
This function listens for changes in the data layer triggered by the data API, This function listens for changes in the data layer triggered by the data API,
@ -65,17 +69,23 @@ export const documentSlice = createSlice({
onDataChange: ( onDataChange: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
data: BlockEventPayloadPB; data: BlockEventPayloadPB;
isRemote: boolean; isRemote: boolean;
}> }>
) => { ) => {
const { path, id, value, command } = action.payload.data; const { docId, data } = action.payload;
const { path, id, value, command } = data;
const documentState = state[docId];
if (!documentState) return;
const valueJson = parseValue(value); const valueJson = parseValue(value);
if (!valueJson) return; if (!valueJson) return;
// match change // match change
matchChange(state, { path, id, value: valueJson, command }); matchChange(documentState, { path, id, value: valueJson, command });
}, },
}, },
}); });
@ -84,20 +94,57 @@ export const rectSelectionSlice = createSlice({
name: 'documentRectSelection', name: 'documentRectSelection',
initialState: rectSelectionInitialState, initialState: rectSelectionInitialState,
reducers: { reducers: {
initialState: (state, action: PayloadAction<string>) => {
const docId = action.payload;
state[docId] = {
selection: [],
isDragging: false,
};
},
clear: (state, action: PayloadAction<string>) => {
const docId = action.payload;
delete state[docId];
},
// update block selections // update block selections
updateSelections: (state, action: PayloadAction<string[]>) => { updateSelections: (
state.selection = action.payload; state,
action: PayloadAction<{
docId: string;
selection: string[];
}>
) => {
const { docId, selection } = action.payload;
state[docId].selection = selection;
}, },
// set block selected // set block selected
setSelectionById: (state, action: PayloadAction<string>) => { setSelectionById: (
const id = action.payload; state,
if (state.selection.includes(id)) return; action: PayloadAction<{
state.selection = [...state.selection, id]; docId: string;
blockId: string;
}>
) => {
const { docId, blockId } = action.payload;
const selection = state[docId].selection;
if (selection.includes(blockId)) return;
state[docId].selection = [...selection, blockId];
}, },
setDragging: (state, action: PayloadAction<boolean>) => { setDragging: (
state.isDragging = action.payload; state,
action: PayloadAction<{
docId: string;
isDragging: boolean;
}>
) => {
const { docId, isDragging } = action.payload;
state[docId].isDragging = isDragging;
}, },
}, },
}); });
@ -106,12 +153,34 @@ export const rangeSlice = createSlice({
name: 'documentRange', name: 'documentRange',
initialState: rangeInitialState, initialState: rangeInitialState,
reducers: { reducers: {
setRanges: (state, action: PayloadAction<RangeState['ranges']>) => { initialState: (state, action: PayloadAction<string>) => {
state.ranges = action.payload; const docId = action.payload;
state[docId] = {
isDragging: false,
ranges: {},
};
},
clear: (state, action: PayloadAction<string>) => {
const docId = action.payload;
delete state[docId];
},
setRanges: (
state,
action: PayloadAction<{
docId: string;
ranges: RangeState['ranges'];
}>
) => {
const { docId, ranges } = action.payload;
state[docId].ranges = ranges;
}, },
setRange: ( setRange: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
id: string; id: string;
rangeStatic: { rangeStatic: {
index: number; index: number;
@ -119,84 +188,178 @@ export const rangeSlice = createSlice({
}; };
}> }>
) => { ) => {
const { id, rangeStatic } = action.payload; const { docId, id, rangeStatic } = action.payload;
state.ranges[id] = rangeStatic;
state[docId].ranges[id] = rangeStatic;
}, },
removeRange: (state, action: PayloadAction<string>) => { removeRange: (
const id = action.payload; state,
delete state.ranges[id]; action: PayloadAction<{
docId: string;
id: string;
}>
) => {
const { docId, id } = action.payload;
const ranges = state[docId].ranges;
delete ranges[id];
}, },
setAnchorPoint: ( setAnchorPoint: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
id: string; id: string;
point: { x: number; y: number }; point: { x: number; y: number };
}> }>
) => { ) => {
state.anchor = action.payload; const { docId, id, point } = action.payload;
state[docId].anchor = {
id,
point,
};
}, },
setAnchorPointRange: ( setAnchorPointRange: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
index: number; index: number;
length: number; length: number;
}> }>
) => { ) => {
const anchor = state.anchor; const { docId, index, length } = action.payload;
const anchor = state[docId].anchor;
if (!anchor) return; if (!anchor) return;
anchor.point = { anchor.point = {
...anchor.point, ...anchor.point,
...action.payload, index,
length,
}; };
}, },
setFocusPoint: ( setFocusPoint: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
id: string; id: string;
point: { x: number; y: number }; point: { x: number; y: number };
}> }>
) => { ) => {
state.focus = action.payload; const { docId, id, point } = action.payload;
state[docId].focus = {
id,
point,
};
}, },
setDragging: (state, action: PayloadAction<boolean>) => { setDragging: (
state.isDragging = action.payload; state,
action: PayloadAction<{
docId: string;
isDragging: boolean;
}>
) => {
const { docId, isDragging } = action.payload;
state[docId].isDragging = isDragging;
}, },
setCaret: (state, action: PayloadAction<RangeStatic | null>) => { setCaret: (
if (!action.payload) { state,
state.caret = undefined; action: PayloadAction<{
docId: string;
caret: RangeStatic | null;
}>
) => {
const { docId, caret } = action.payload;
const rangeState = state[docId];
if (!caret) {
rangeState.caret = undefined;
return; return;
} }
const id = action.payload.id;
state.ranges[id] = { const { id, index, length } = caret;
index: action.payload.index,
length: action.payload.length, rangeState.ranges[id] = {
index,
length,
}; };
state.caret = action.payload; rangeState.caret = caret;
}, },
clearRange: (state, _: PayloadAction) => { clearRanges: (
return rangeInitialState; state,
action: PayloadAction<{
docId: string;
exclude?: string;
}>
) => {
const { docId, exclude } = action.payload;
const ranges = state[docId].ranges;
const newRanges = Object.keys(ranges).reduce((acc, id) => {
if (id !== exclude) return { ...acc };
return {
...acc,
[id]: ranges[id],
};
}, {});
state[docId].ranges = newRanges;
}, },
}, },
}); });
export const slashCommandSlice = createSlice({ export const slashCommandSlice = createSlice({
name: 'documentSlashCommand', name: 'documentSlashCommand',
initialState: slashCommandInitialState, initialState: slashCommandInitialState,
reducers: { reducers: {
initialState: (state, action: PayloadAction<string>) => {
const docId = action.payload;
state[docId] = {
isSlashCommand: false,
};
},
clear: (state, action: PayloadAction<string>) => {
const docId = action.payload;
delete state[docId];
},
openSlashCommand: ( openSlashCommand: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
docId: string;
blockId: string; blockId: string;
}> }>
) => { ) => {
const { blockId } = action.payload; const { blockId, docId } = action.payload;
return {
...state, state[docId] = {
...state[docId],
isSlashCommand: true, isSlashCommand: true,
blockId, blockId,
}; };
}, },
closeSlashCommand: (state, _: PayloadAction) => { closeSlashCommand: (state, action: PayloadAction<string>) => {
return slashCommandInitialState; const docId = action.payload;
state[docId] = {
...state[docId],
isSlashCommand: false,
};
},
setHoverOption: (
state,
action: PayloadAction<{
docId: string;
option: SlashCommandOption;
}>
) => {
const { docId, option } = action.payload;
state[docId] = {
...state[docId],
hoverOption: option,
};
}, },
}, },
}); });
@ -205,28 +368,46 @@ export const linkPopoverSlice = createSlice({
name: 'documentLinkPopover', name: 'documentLinkPopover',
initialState: linkPopoverState, initialState: linkPopoverState,
reducers: { reducers: {
setLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => { initialState: (state, action: PayloadAction<string>) => {
return { const docId = action.payload;
...state,
...action.payload, state[docId] = {
};
},
updateLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
const { id } = action.payload;
if (!state.open || state.id !== id) return;
return {
...state,
...action.payload,
};
},
closeLinkPopover: (state, _: PayloadAction) => {
return {
...state,
open: false, open: false,
}; };
}, },
resetLinkPopover: (state, _: PayloadAction) => { clear: (state, action: PayloadAction<string>) => {
return linkPopoverState; const docId = action.payload;
delete state[docId];
},
setLinkPopover: (
state,
action: PayloadAction<{
docId: string;
linkState: LinkPopoverState;
}>
) => {
const { docId, linkState } = action.payload;
state[docId] = linkState;
},
updateLinkPopover: (
state,
action: PayloadAction<{
docId: string;
linkState: LinkPopoverState;
}>
) => {
const { docId, linkState } = action.payload;
const { id } = linkState;
if (!state[docId].open || state[docId].id !== id) return;
state[docId] = linkState;
},
closeLinkPopover: (state, action: PayloadAction<string>) => {
const docId = action.payload;
state[docId].open = false;
}, },
}, },
}); });

View File

@ -27,40 +27,50 @@ import {
export function getMiddleIds(document: DocumentState, startId: string, endId: string) { export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
const middleIds = []; const middleIds = [];
let currentId: string | undefined = startId; let currentId: string | undefined = startId;
while (currentId && currentId !== endId) { while (currentId && currentId !== endId) {
const nextId = getNextLineId(document, currentId); const nextId = getNextLineId(document, currentId);
if (nextId && nextId !== endId) { if (nextId && nextId !== endId) {
middleIds.push(nextId); middleIds.push(nextId);
} }
currentId = nextId; currentId = nextId;
} }
return middleIds; return middleIds;
} }
export function getStartAndEndIdsByRange(rangeState: RangeState) { export function getStartAndEndIdsByRange(rangeState: RangeState) {
const { anchor, focus } = rangeState; const { anchor, focus } = rangeState;
if (!anchor || !focus) return []; if (!anchor || !focus) return [];
if (anchor.id === focus.id) return [anchor.id]; if (anchor.id === focus.id) return [anchor.id];
const isForward = anchor.point.y < focus.point.y; const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id; const startId = isForward ? anchor.id : focus.id;
const endId = isForward ? focus.id : anchor.id; const endId = isForward ? focus.id : anchor.id;
return [startId, endId]; return [startId, endId];
} }
export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) { export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
const ids = getStartAndEndIdsByRange(rangeState); const ids = getStartAndEndIdsByRange(rangeState);
if (ids.length < 2) return; if (ids.length < 2) return;
const [startId, endId] = ids; const [startId, endId] = ids;
return getMiddleIds(document, startId, endId); return getMiddleIds(document, startId, endId);
} }
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) { export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
const { anchor, focus, ranges } = rangeState; const { anchor, focus, ranges } = rangeState;
if (!anchor || !focus) return; if (!anchor || !focus) return;
const isForward = anchor.point.y < focus.point.y; const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id; const startId = isForward ? anchor.id : focus.id;
const startRange = ranges[startId]; const startRange = ranges[startId];
if (!startRange) return; if (!startRange) return;
const offset = insertDelta ? insertDelta.length() : 0; const offset = insertDelta ? insertDelta.length() : 0;
@ -71,9 +81,9 @@ export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?:
}; };
} }
export function getStartAndEndExtentDelta(state: RootState) { export function getStartAndEndExtentDelta(documentState: DocumentState, rangeState: RangeState) {
const rangeState = state.documentRange;
const ids = getStartAndEndIdsByRange(rangeState); const ids = getStartAndEndIdsByRange(rangeState);
if (ids.length === 0) return; if (ids.length === 0) return;
const startId = ids[0]; const startId = ids[0];
const endId = ids[ids.length - 1]; const endId = ids[ids.length - 1];
@ -81,12 +91,13 @@ export function getStartAndEndExtentDelta(state: RootState) {
// get start and end delta // get start and end delta
const startRange = ranges[startId]; const startRange = ranges[startId];
const endRange = ranges[endId]; const endRange = ranges[endId];
if (!startRange || !endRange) return; if (!startRange || !endRange) return;
const startNode = state.document.nodes[startId]; const startNode = documentState.nodes[startId];
const startNodeDelta = new Delta(startNode.data.delta); const startNodeDelta = new Delta(startNode.data.delta);
const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange); const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
const endNode = state.document.nodes[endId]; const endNode = documentState.nodes[endId];
const endNodeDelta = new Delta(endNode.data.delta); const endNodeDelta = new Delta(endNode.data.delta);
const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange); const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
@ -104,10 +115,15 @@ export function getMergeEndDeltaToStartActionsByRange(
insertDelta?: Delta insertDelta?: Delta
) { ) {
const actions = []; const actions = [];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {}; const docId = controller.documentId;
const documentState = state.document[docId];
const rangeState = state.documentRange[docId];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
if (!startDelta || !endDelta || !endNode || !startNode) return; if (!startDelta || !endDelta || !endNode || !startNode) return;
// merge start and end nodes // merge start and end nodes
const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta); const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
actions.push( actions.push(
controller.getUpdateAction({ controller.getUpdateAction({
...startNode, ...startNode,
@ -117,13 +133,14 @@ export function getMergeEndDeltaToStartActionsByRange(
}) })
); );
if (endNode.id !== startNode.id) { if (endNode.id !== startNode.id) {
const children = state.document.children[endNode.children].map((id) => state.document.nodes[id]); const children = documentState.children[endNode.children].map((id) => documentState.nodes[id]);
const moveChildrenActions = getMoveChildrenActions({ const moveChildrenActions = getMoveChildrenActions({
target: startNode, target: startNode,
children, children,
controller, controller,
}); });
actions.push(...moveChildrenActions); actions.push(...moveChildrenActions);
// delete end node // delete end node
actions.push(controller.getDeleteAction(endNode)); actions.push(controller.getDeleteAction(endNode));
@ -146,9 +163,11 @@ export function getMoveChildrenActions({
// move children // move children
const config = blockConfig[target.type]; const config = blockConfig[target.type];
const targetParentId = config.canAddChild ? target.id : target.parent; const targetParentId = config.canAddChild ? target.id : target.parent;
if (!targetParentId) return []; if (!targetParentId) return [];
const targetPrevId = targetParentId === target.id ? prevId : target.id; const targetPrevId = targetParentId === target.id ? prevId : target.id;
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId); const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
return moveActions; return moveActions;
} }
@ -164,10 +183,12 @@ export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
const newNodeType = config.nextLineBlockType; const newNodeType = config.nextLineBlockType;
const relationShip = config.nextLineRelationShip; const relationShip = config.nextLineRelationShip;
const defaultData = blockConfig[newNodeType].defaultData; const defaultData = blockConfig[newNodeType].defaultData;
// if the defaultData property is not defined for the new block type, we throw an error. // if the defaultData property is not defined for the new block type, we throw an error.
if (!defaultData) { if (!defaultData) {
throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`); throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
} }
const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id; const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : ''; const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
@ -185,6 +206,7 @@ export function getInsertEnterNodeAction(
controller: DocumentController controller: DocumentController
) { ) {
const insertNodeFields = getInsertEnterNodeFields(sourceNode); const insertNodeFields = getInsertEnterNodeFields(sourceNode);
if (!insertNodeFields) return; if (!insertNodeFields) return;
const { type, data, parentId, prevId } = insertNodeFields; const { type, data, parentId, prevId } = insertNodeFields;
const insertNode = newBlock<any>(type, parentId, { const insertNode = newBlock<any>(type, parentId, {
@ -200,27 +222,35 @@ export function getInsertEnterNodeAction(
export function findPrevHasDeltaNode(state: DocumentState, id: string) { export function findPrevHasDeltaNode(state: DocumentState, id: string) {
const prevLineId = getPrevLineId(state, id); const prevLineId = getPrevLineId(state, id);
if (!prevLineId) return; if (!prevLineId) return;
let prevLine = state.nodes[prevLineId]; let prevLine = state.nodes[prevLineId];
// Find the prev line that has delta // Find the prev line that has delta
while (prevLine && !prevLine.data.delta) { while (prevLine && !prevLine.data.delta) {
const id = getPrevLineId(state, prevLine.id); const id = getPrevLineId(state, prevLine.id);
if (!id) return; if (!id) return;
prevLine = state.nodes[id]; prevLine = state.nodes[id];
} }
return prevLine; return prevLine;
} }
export function findNextHasDeltaNode(state: DocumentState, id: string) { export function findNextHasDeltaNode(state: DocumentState, id: string) {
const nextLineId = getNextLineId(state, id); const nextLineId = getNextLineId(state, id);
if (!nextLineId) return; if (!nextLineId) return;
let nextLine = state.nodes[nextLineId]; let nextLine = state.nodes[nextLineId];
// Find the next line that has delta // Find the next line that has delta
while (nextLine && !nextLine.data.delta) { while (nextLine && !nextLine.data.delta) {
const id = getNextLineId(state, nextLine.id); const id = getNextLineId(state, nextLine.id);
if (!id) return; if (!id) return;
nextLine = state.nodes[id]; nextLine = state.nodes[id];
} }
return nextLine; return nextLine;
} }
@ -233,11 +263,13 @@ export function isPrintableKeyEvent(event: KeyboardEvent) {
export function getLeftCaretByRange(rangeState: RangeState) { export function getLeftCaretByRange(rangeState: RangeState) {
const { anchor, ranges, focus } = rangeState; const { anchor, ranges, focus } = rangeState;
if (!anchor || !focus) return; if (!anchor || !focus) return;
const isForward = anchor.point.y < focus.point.y; const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id; const startId = isForward ? anchor.id : focus.id;
const range = ranges[startId]; const range = ranges[startId];
if (!range) return; if (!range) return;
return { return {
id: startId, id: startId,
@ -248,11 +280,13 @@ export function getLeftCaretByRange(rangeState: RangeState) {
export function getRightCaretByRange(rangeState: RangeState) { export function getRightCaretByRange(rangeState: RangeState) {
const { anchor, focus, ranges, caret } = rangeState; const { anchor, focus, ranges, caret } = rangeState;
if (!anchor || !focus) return; if (!anchor || !focus) return;
const isForward = anchor.point.y < focus.point.y; const isForward = anchor.point.y < focus.point.y;
const endId = isForward ? focus.id : anchor.id; const endId = isForward ? focus.id : anchor.id;
const range = ranges[endId]; const range = ranges[endId];
if (!range) return; if (!range) return;
return { return {
@ -268,13 +302,16 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
if (!inTopEdge) { if (!inTopEdge) {
const index = transformIndexToPrevLine(delta, caret.index); const index = transformIndexToPrevLine(delta, caret.index);
return { return {
id: caret.id, id: caret.id,
index, index,
length: 0, length: 0,
}; };
} }
const prevLine = findPrevHasDeltaNode(document, caret.id); const prevLine = findPrevHasDeltaNode(document, caret.id);
if (!prevLine) return; if (!prevLine) return;
const relativeIndex = getIndexRelativeEnter(delta, caret.index); const relativeIndex = getIndexRelativeEnter(delta, caret.index);
const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta)); const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
@ -282,6 +319,7 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
const newPrevLineIndex = prevLineIndex + relativeIndex; const newPrevLineIndex = prevLineIndex + relativeIndex;
const prevLineLength = prevLineText.length; const prevLineLength = prevLineText.length;
const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex; const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
return { return {
id: prevLine.id, id: prevLine.id,
index, index,
@ -292,8 +330,10 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) { export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
const delta = new Delta(document.nodes[caret.id].data.delta); const delta = new Delta(document.nodes[caret.id].data.delta);
const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index); const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
if (!inBottomEdge) { if (!inBottomEdge) {
const index = transformIndexToNextLine(delta, caret.index); const index = transformIndexToNextLine(delta, caret.index);
return { return {
id: caret.id, id: caret.id,
index, index,
@ -303,6 +343,7 @@ export function transformToNextLineCaret(document: DocumentState, caret: RangeSt
} }
const nextLine = findNextHasDeltaNode(document, caret.id); const nextLine = findNextHasDeltaNode(document, caret.id);
if (!nextLine) return; if (!nextLine) return;
const nextLineText = getDeltaText(new Delta(nextLine.data.delta)); const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
const relativeIndex = getIndexRelativeEnter(delta, caret.index); const relativeIndex = getIndexRelativeEnter(delta, caret.index);
@ -323,15 +364,19 @@ export function getDuplicateActions(
) { ) {
const actions: ControllerAction[] = []; const actions: ControllerAction[] = [];
const node = document.nodes[id]; const node = document.nodes[id];
if (!node) return; if (!node) return;
// duplicate new node // duplicate new node
const newNode = newBlock<any>(node.type, parentId, { const newNode = newBlock<any>(node.type, parentId, {
...node.data, ...node.data,
}); });
actions.push(controller.getInsertAction(newNode, node.id)); actions.push(controller.getInsertAction(newNode, node.id));
const children = document.children[node.children]; const children = document.children[node.children];
children.forEach((child) => { children.forEach((child) => {
const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller); const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller);
if (!duplicateChildActions) return; if (!duplicateChildActions) return;
actions.push(...duplicateChildActions.actions); actions.push(...duplicateChildActions.actions);
}); });

View File

@ -3,7 +3,6 @@ import { getDeltaByRange } from '$app/utils/document/delta';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import { generateId } from '$app/utils/document/block'; import { generateId } from '$app/utils/document/block';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { blockConfig } from '$app/constants/document/config';
export function getCopyData( export function getCopyData(
node: NestedBlock, node: NestedBlock,

View File

@ -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];
}

View File

@ -194,7 +194,11 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
const selection = window.getSelection(); const selection = window.getSelection();
selection?.removeAllRanges(); selection?.removeAllRanges();
selection?.addRange(range); selection?.addRange(range);
return true; const focusNode = selection?.focusNode;
if (!focusNode) return false;
const parent = findParent(focusNode as Element, node);
return Boolean(parent);
} }
export function getNodeTextBoxByBlockId(blockId: string) { export function getNodeTextBoxByBlockId(blockId: string) {
@ -225,41 +229,16 @@ export function replaceZeroWidthSpace(text: string) {
return text.replace(/[\u200B-\u200D\uFEFF]/g, ''); return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
} }
export function findParent(node: Element, parentSelector: string) { export function findParent(node: Element, parentSelector: string | Element) {
let parentNode: Element | null = node; let parentNode: Element | null = node;
while (parentNode) { while (parentNode) {
if (parentNode.matches(parentSelector)) { if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
return parentNode;
}
if (parentNode === parentSelector) {
return parentNode; return parentNode;
} }
parentNode = parentNode.parentElement; parentNode = parentNode.parentElement;
} }
return null; return null;
} }
export function getWordIndices(startContainer: Node, startOffset: number) {
const textNode = startContainer;
const textContent = textNode.textContent || '';
const wordRegex = /\b\w+\b/g;
let match;
const wordIndices = [];
while ((match = wordRegex.exec(textContent)) !== null) {
const word = match[0];
const wordIndex = match.index;
const wordEndIndex = wordIndex + word.length;
// If the startOffset is greater than the wordIndex and less than the wordEndIndex, then the startOffset is
if (startOffset > wordIndex && startOffset <= wordEndIndex) {
wordIndices.push({
word: word,
startIndex: wordIndex,
endIndex: wordEndIndex,
});
break;
}
}
// If there are no matches, then the startOffset is greater than the last wordEndIndex
return wordIndices;
}

View File

@ -1,8 +1,8 @@
import { DeltaTypePB } from "@/services/backend/models/flowy-document2"; import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from "$app/interfaces/document"; import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
import { Log } from "../log"; import { Log } from '../log';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from "$app/constants/document/block"; import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
import { isEqual } from "$app/utils/tool"; import { isEqual } from '$app/utils/tool';
// This is a list of all the possible changes that can happen to document data // This is a list of all the possible changes that can happen to document data
const matchCases = [ const matchCases = [
@ -26,7 +26,7 @@ export function matchChange(
path: string[]; path: string[];
id: string; id: string;
value: BlockPBValue & string[]; value: BlockPBValue & string[];
}, }
) { ) {
const matchCase = matchCases.find((item) => item.match(command, path)); const matchCase = matchCases.find((item) => item.match(command, path));
@ -106,7 +106,9 @@ function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: B
function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) { function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
const block = blockChangeValue2Node(blockValue); const block = blockChangeValue2Node(blockValue);
const node = state.nodes[blockId]; const node = state.nodes[blockId];
if (!node) return; if (!node) return;
if (isEqual(node, block)) return; if (isEqual(node, block)) return;
state.nodes[blockId] = block; state.nodes[blockId] = block;
return; return;
@ -122,6 +124,7 @@ function onMatchChildrenInsert(state: DocumentState, id: string, children: strin
function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) { function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) {
const children = state.children[id]; const children = state.children[id];
if (!children) return; if (!children) return;
state.children[id] = newChildren; state.children[id] = newChildren;
} }
@ -144,6 +147,7 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
delta: [], delta: [],
}, },
}; };
if ('data' in value && typeof value.data === 'string') { if ('data' in value && typeof value.data === 'string') {
try { try {
Object.assign(block, { Object.assign(block, {
@ -159,11 +163,13 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
export function parseValue(value: string) { export function parseValue(value: string) {
let valueJson; let valueJson;
try { try {
valueJson = JSON.parse(value); valueJson = JSON.parse(value);
} catch { } catch {
Log.error('[onDataChange] json parse error', value); Log.error('[onDataChange] json parse error', value);
return; return;
} }
return valueJson; return valueJson;
} }

View File

@ -4,7 +4,13 @@ import { DocumentData } from '../interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '../stores/store'; import { useAppDispatch } from '../stores/store';
import { Log } from '../utils/log'; import { Log } from '../utils/log';
import { documentActions } from '../stores/reducers/document/slice'; import {
documentActions,
linkPopoverActions,
rangeActions,
rectSelectionActions,
slashCommandActions,
} from '$app/stores/reducers/document/slice';
import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2'; import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2';
export const useDocument = () => { export const useDocument = () => {
@ -14,20 +20,60 @@ export const useDocument = () => {
const [controller, setController] = useState<DocumentController | null>(null); const [controller, setController] = useState<DocumentController | null>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onDocumentChange = useCallback((props: { isRemote: boolean; data: BlockEventPayloadPB }) => { const onDocumentChange = useCallback(
dispatch(documentActions.onDataChange(props)); (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => {
}, []); dispatch(documentActions.onDataChange(props));
},
[dispatch]
);
const initializeDocument = useCallback(
(docId: string) => {
Log.debug('initialize document', docId);
dispatch(documentActions.initialState(docId));
dispatch(rangeActions.initialState(docId));
dispatch(rectSelectionActions.initialState(docId));
dispatch(slashCommandActions.initialState(docId));
dispatch(linkPopoverActions.initialState(docId));
},
[dispatch]
);
const clearDocument = useCallback(
(docId: string) => {
Log.debug('clear document', docId);
dispatch(documentActions.clear(docId));
dispatch(rangeActions.clear(docId));
dispatch(rectSelectionActions.clear(docId));
dispatch(slashCommandActions.clear(docId));
dispatch(linkPopoverActions.clear(docId));
},
[dispatch]
);
useEffect(() => { useEffect(() => {
let documentController: DocumentController | null = null; let documentController: DocumentController | null = null;
void (async () => { void (async () => {
if (!params?.id) return; if (!params?.id) return;
Log.debug('open document', params.id);
documentController = new DocumentController(params.id, onDocumentChange); documentController = new DocumentController(params.id, onDocumentChange);
const docId = documentController.documentId;
Log.debug('open document', params.id);
initializeDocument(documentController.documentId);
setController(documentController); setController(documentController);
try { try {
const res = await documentController.open(); const res = await documentController.open();
if (!res) return; if (!res) return;
dispatch(
documentActions.create({
...res,
docId,
})
);
setDocumentData(res); setDocumentData(res);
setDocumentId(params.id); setDocumentId(params.id);
} catch (e) { } catch (e) {
@ -35,15 +81,17 @@ export const useDocument = () => {
} }
})(); })();
const closeDocument = () => { return () => {
if (documentController) { if (documentController) {
void documentController.dispose(); void (async () => {
await documentController.dispose();
clearDocument(documentController.documentId);
})();
} }
Log.debug('close document', params.id); Log.debug('close document', params.id);
}; };
}, [clearDocument, dispatch, initializeDocument, onDocumentChange, params.id]);
return closeDocument;
}, [params.id]);
return { documentId, documentData, controller }; return { documentId, documentData, controller };
}; };

View File

@ -6,7 +6,7 @@ import { DocumentControllerContext } from '../stores/effects/document/document_c
const muiTheme = createTheme({ const muiTheme = createTheme({
typography: { typography: {
fontFamily: ['Poppins'].join(','), fontFamily: ['Poppins'].join(','),
fontSize: 14, fontSize: 12,
}, },
palette: { palette: {
primary: { primary: {

View File

@ -105,7 +105,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
let json_str = include_str!("../../assets/read_me.json"); let json_str = include_str!("../../assets/read_me.json");
let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap(); let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap();
manager manager
.create_document(view.parent_view.id.clone(), Some(document_pb.into())) .create_document(&view.parent_view.id, Some(document_pb.into()))
.unwrap(); .unwrap();
view view
}) })
@ -143,7 +143,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
let manager = self.0.clone(); let manager = self.0.clone();
let view_id = view_id.to_string(); let view_id = view_id.to_string();
FutureResult::new(async move { FutureResult::new(async move {
let document = manager.get_document_from_disk(view_id)?; let document = manager.get_document_from_disk(&view_id)?;
let data: DocumentDataPB = document.lock().get_document()?.into(); let data: DocumentDataPB = document.lock().get_document()?.into();
let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?; let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?;
Ok(data_bytes) Ok(data_bytes)
@ -164,7 +164,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
let manager = self.0.clone(); let manager = self.0.clone();
FutureResult::new(async move { FutureResult::new(async move {
let data = DocumentDataPB::try_from(Bytes::from(data))?; let data = DocumentDataPB::try_from(Bytes::from(data))?;
manager.create_document(view_id, Some(data.into()))?; manager.create_document(&view_id, Some(data.into()))?;
Ok(()) Ok(())
}) })
} }
@ -181,7 +181,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
let view_id = view_id.to_string(); let view_id = view_id.to_string();
let manager = self.0.clone(); let manager = self.0.clone();
FutureResult::new(async move { FutureResult::new(async move {
manager.create_document(view_id, None)?; manager.create_document(&view_id, None)?;
Ok(()) Ok(())
}) })
} }
@ -197,7 +197,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
let manager = self.0.clone(); let manager = self.0.clone();
FutureResult::new(async move { FutureResult::new(async move {
let data = DocumentDataPB::try_from(Bytes::from(bytes))?; let data = DocumentDataPB::try_from(Bytes::from(bytes))?;
manager.create_document(view_id, Some(data.into()))?; manager.create_document(&view_id, Some(data.into()))?;
Ok(()) Ok(())
}) })
} }

View File

@ -35,7 +35,7 @@ pub(crate) async fn create_document_handler(
manager: AFPluginState<Arc<DocumentManager>>, manager: AFPluginState<Arc<DocumentManager>>,
) -> FlowyResult<()> { ) -> FlowyResult<()> {
let params: CreateDocumentParams = data.into_inner().try_into()?; let params: CreateDocumentParams = data.into_inner().try_into()?;
manager.create_document(params.document_id, params.initial_data)?; manager.create_document(&params.document_id, params.initial_data)?;
Ok(()) Ok(())
} }
@ -46,7 +46,7 @@ pub(crate) async fn open_document_handler(
) -> DataResult<DocumentDataPB, FlowyError> { ) -> DataResult<DocumentDataPB, FlowyError> {
let params: OpenDocumentParams = data.into_inner().try_into()?; let params: OpenDocumentParams = data.into_inner().try_into()?;
let doc_id = params.document_id; let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?; let document = manager.get_or_open_document(&doc_id)?;
let document_data = document.lock().get_document()?; let document_data = document.lock().get_document()?;
data_result_ok(DocumentDataPB::from(document_data)) data_result_ok(DocumentDataPB::from(document_data))
} }
@ -69,7 +69,7 @@ pub(crate) async fn get_document_data_handler(
) -> DataResult<DocumentDataPB, FlowyError> { ) -> DataResult<DocumentDataPB, FlowyError> {
let params: OpenDocumentParams = data.into_inner().try_into()?; let params: OpenDocumentParams = data.into_inner().try_into()?;
let doc_id = params.document_id; let doc_id = params.document_id;
let document = manager.get_document_from_disk(doc_id)?; let document = manager.get_document_from_disk(&doc_id)?;
let document_data = document.lock().get_document()?; let document_data = document.lock().get_document()?;
data_result_ok(DocumentDataPB::from(document_data)) data_result_ok(DocumentDataPB::from(document_data))
} }
@ -81,7 +81,7 @@ pub(crate) async fn apply_action_handler(
) -> FlowyResult<()> { ) -> FlowyResult<()> {
let params: ApplyActionParams = data.into_inner().try_into()?; let params: ApplyActionParams = data.into_inner().try_into()?;
let doc_id = params.document_id; let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?; let document = manager.get_or_open_document(&doc_id)?;
let actions = params.actions; let actions = params.actions;
document.lock().apply_action(actions); document.lock().apply_action(actions);
Ok(()) Ok(())
@ -117,7 +117,7 @@ pub(crate) async fn redo_handler(
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> { ) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
let params: DocumentRedoUndoParams = data.into_inner().try_into()?; let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
let doc_id = params.document_id; let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?; let document = manager.get_or_open_document(&doc_id)?;
let document = document.lock(); let document = document.lock();
let redo = document.redo(); let redo = document.redo();
let can_redo = document.can_redo(); let can_redo = document.can_redo();
@ -135,7 +135,7 @@ pub(crate) async fn undo_handler(
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> { ) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
let params: DocumentRedoUndoParams = data.into_inner().try_into()?; let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
let doc_id = params.document_id; let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?; let document = manager.get_or_open_document(&doc_id)?;
let document = document.lock(); let document = document.lock();
let undo = document.undo(); let undo = document.undo();
let can_redo = document.can_redo(); let can_redo = document.can_redo();
@ -153,7 +153,7 @@ pub(crate) async fn can_undo_redo_handler(
) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> { ) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
let params: DocumentRedoUndoParams = data.into_inner().try_into()?; let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
let doc_id = params.document_id; let doc_id = params.document_id;
let document = manager.get_or_open_document(doc_id)?; let document = manager.get_or_open_document(&doc_id)?;
let document = document.lock(); let document = document.lock();
let can_redo = document.can_redo(); let can_redo = document.can_redo();
let can_undo = document.can_undo(); let can_undo = document.can_undo();

View File

@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc};
use appflowy_integrate::collab_builder::AppFlowyCollabBuilder; use appflowy_integrate::collab_builder::AppFlowyCollabBuilder;
use appflowy_integrate::RocksCollabDB; use appflowy_integrate::RocksCollabDB;
use collab_document::blocks::DocumentData; use collab_document::blocks::DocumentData;
use collab_document::error::DocumentError;
use collab_document::YrsDocAction; use collab_document::YrsDocAction;
use parking_lot::RwLock; use parking_lot::RwLock;
@ -42,13 +43,13 @@ impl DocumentManager {
/// if the data is None, will create a document with default data. /// if the data is None, will create a document with default data.
pub fn create_document( pub fn create_document(
&self, &self,
doc_id: String, doc_id: &str,
data: Option<DocumentData>, data: Option<DocumentData>,
) -> FlowyResult<Arc<Document>> { ) -> FlowyResult<Arc<Document>> {
tracing::debug!("create a document: {:?}", &doc_id); tracing::debug!("create a document: {:?}", doc_id);
let uid = self.user.user_id()?; let uid = self.user.user_id()?;
let db = self.user.collab_db()?; let db = self.user.collab_db()?;
let collab = self.collab_builder.build(uid, &doc_id, "document", db); let collab = self.collab_builder.build(uid, doc_id, "document", db);
let data = data.unwrap_or_else(default_document_data); let data = data.unwrap_or_else(default_document_data);
let document = Arc::new(Document::create_with_data(collab, data)?); let document = Arc::new(Document::create_with_data(collab, data)?);
Ok(document) Ok(document)
@ -56,22 +57,34 @@ impl DocumentManager {
/// get document /// get document
/// read the existing document from the map if it exists, otherwise read it from the disk and write it to the map. /// read the existing document from the map if it exists, otherwise read it from the disk and write it to the map.
pub fn get_or_open_document(&self, doc_id: String) -> FlowyResult<Arc<Document>> { pub fn get_or_open_document(&self, doc_id: &str) -> FlowyResult<Arc<Document>> {
if let Some(doc) = self.documents.read().get(&doc_id) { if let Some(doc) = self.documents.read().get(doc_id) {
return Ok(doc.clone()); return Ok(doc.clone());
} }
tracing::debug!("open_document: {:?}", &doc_id); tracing::debug!("open_document: {:?}", doc_id);
// read the existing document from the disk. // read the existing document from the disk.
let document = self.get_document_from_disk(doc_id.clone())?; let document = self.get_document_from_disk(&doc_id)?;
// save the document to the memory and read it from the memory if we open the same document again. // save the document to the memory and read it from the memory if we open the same document again.
// and we don't want to subscribe to the document changes if we open the same document again. // and we don't want to subscribe to the document changes if we open the same document again.
self self
.documents .documents
.write() .write()
.insert(doc_id.clone(), document.clone()); .insert(doc_id.to_string(), document.clone());
// subscribe to the document changes. // subscribe to the document changes.
document.lock().open(move |events, is_remote| { self.subscribe_document_changes(document.clone(), doc_id)?;
Ok(document)
}
pub fn subscribe_document_changes(
&self,
document: Arc<Document>,
doc_id: &str,
) -> Result<DocumentData, DocumentError> {
let mut document = document.lock();
let doc_id = doc_id.to_string();
document.open(move |events, is_remote| {
tracing::trace!( tracing::trace!(
"document changed: {:?}, from remote: {}", "document changed: {:?}, from remote: {}",
&events, &events,
@ -81,17 +94,15 @@ impl DocumentManager {
send_notification(&doc_id, DocumentNotification::DidReceiveUpdate) send_notification(&doc_id, DocumentNotification::DidReceiveUpdate)
.payload::<DocEventPB>((events, is_remote).into()) .payload::<DocEventPB>((events, is_remote).into())
.send(); .send();
})?; })
Ok(document)
} }
/// get document /// get document
/// read the existing document from the disk. /// read the existing document from the disk.
pub fn get_document_from_disk(&self, doc_id: String) -> FlowyResult<Arc<Document>> { pub fn get_document_from_disk(&self, doc_id: &str) -> FlowyResult<Arc<Document>> {
let uid = self.user.user_id()?; let uid = self.user.user_id()?;
let db = self.user.collab_db()?; let db = self.user.collab_db()?;
let collab = self.collab_builder.build(uid, &doc_id, "document", db); let collab = self.collab_builder.build(uid, doc_id, "document", db);
// read the existing document from the disk. // read the existing document from the disk.
let document = Arc::new(Document::new(collab)?); let document = Arc::new(Document::new(collab)?);
Ok(document) Ok(document)

View File

@ -15,10 +15,10 @@ async fn undo_redo_test() {
let data = default_document_data(); let data = default_document_data();
// create a document // create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone())); _ = manager.create_document(&doc_id, Some(data.clone()));
// open a document // open a document
let document = manager.get_or_open_document(doc_id.clone()).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
let document = document.lock(); let document = document.lock();
let page_block = document.get_block(&data.page_id).unwrap(); let page_block = document.get_block(&data.page_id).unwrap();
let page_id = page_block.id; let page_id = page_block.id;

View File

@ -20,14 +20,14 @@ fn restore_document() {
let doc_id: String = gen_document_id(); let doc_id: String = gen_document_id();
let data = default_document_data(); let data = default_document_data();
let document_a = manager let document_a = manager
.create_document(doc_id.clone(), Some(data.clone())) .create_document(&doc_id, Some(data.clone()))
.unwrap(); .unwrap();
let data_a = document_a.lock().get_document().unwrap(); let data_a = document_a.lock().get_document().unwrap();
assert_eq!(data_a, data); assert_eq!(data_a, data);
// open a document // open a document
let data_b = manager let data_b = manager
.get_or_open_document(doc_id.clone()) .get_or_open_document(&doc_id)
.unwrap() .unwrap()
.lock() .lock()
.get_document() .get_document()
@ -37,10 +37,10 @@ fn restore_document() {
assert_eq!(data_b, data); assert_eq!(data_b, data);
// restore // restore
_ = manager.create_document(doc_id.clone(), Some(data.clone())); _ = manager.create_document(&doc_id, Some(data.clone()));
// open a document // open a document
let data_b = manager let data_b = manager
.get_or_open_document(doc_id.clone()) .get_or_open_document(&doc_id)
.unwrap() .unwrap()
.lock() .lock()
.get_document() .get_document()
@ -60,10 +60,10 @@ fn document_apply_insert_action() {
let data = default_document_data(); let data = default_document_data();
// create a document // create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone())); _ = manager.create_document(&doc_id, Some(data.clone()));
// open a document // open a document
let document = manager.get_or_open_document(doc_id.clone()).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
let page_block = document.lock().get_block(&data.page_id).unwrap(); let page_block = document.lock().get_block(&data.page_id).unwrap();
// insert a text block // insert a text block
@ -91,7 +91,7 @@ fn document_apply_insert_action() {
// re-open the document // re-open the document
let data_b = manager let data_b = manager
.get_or_open_document(doc_id.clone()) .get_or_open_document(&doc_id)
.unwrap() .unwrap()
.lock() .lock()
.get_document() .get_document()
@ -111,10 +111,10 @@ fn document_apply_update_page_action() {
let data = default_document_data(); let data = default_document_data();
// create a document // create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone())); _ = manager.create_document(&doc_id, Some(data.clone()));
// open a document // open a document
let document = manager.get_or_open_document(doc_id.clone()).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
let page_block = document.lock().get_block(&data.page_id).unwrap(); let page_block = document.lock().get_block(&data.page_id).unwrap();
let mut page_block_clone = page_block; let mut page_block_clone = page_block;
@ -138,7 +138,7 @@ fn document_apply_update_page_action() {
_ = manager.close_document(&doc_id); _ = manager.close_document(&doc_id);
// re-open the document // re-open the document
let document = manager.get_or_open_document(doc_id).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
let page_block_new = document.lock().get_block(&data.page_id).unwrap(); let page_block_new = document.lock().get_block(&data.page_id).unwrap();
assert_eq!(page_block_old, page_block_new); assert_eq!(page_block_old, page_block_new);
assert!(page_block_new.data.contains_key("delta")); assert!(page_block_new.data.contains_key("delta"));
@ -153,10 +153,10 @@ fn document_apply_update_action() {
let data = default_document_data(); let data = default_document_data();
// create a document // create a document
_ = manager.create_document(doc_id.clone(), Some(data.clone())); _ = manager.create_document(&doc_id, Some(data.clone()));
// open a document // open a document
let document = manager.get_or_open_document(doc_id.clone()).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
let page_block = document.lock().get_block(&data.page_id).unwrap(); let page_block = document.lock().get_block(&data.page_id).unwrap();
// insert a text block // insert a text block
@ -206,7 +206,7 @@ fn document_apply_update_action() {
_ = manager.close_document(&doc_id); _ = manager.close_document(&doc_id);
// re-open the document // re-open the document
let document = manager.get_or_open_document(doc_id.clone()).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
let block = document.lock().get_block(&text_block_id).unwrap(); let block = document.lock().get_block(&text_block_id).unwrap();
assert_eq!(block.data, updated_text_block_data); assert_eq!(block.data, updated_text_block_data);
// close a document // close a document

View File

@ -66,10 +66,10 @@ pub fn create_and_open_empty_document() -> (DocumentManager, Arc<Document>, Stri
// create a document // create a document
_ = manager _ = manager
.create_document(doc_id.clone(), Some(data.clone())) .create_document(&doc_id, Some(data.clone()))
.unwrap(); .unwrap();
let document = manager.get_or_open_document(doc_id).unwrap(); let document = manager.get_or_open_document(&doc_id).unwrap();
(manager, document, data.page_id) (manager, document, data.page_id)
} }