feat: support views drag and drop (#3004)

This commit is contained in:
Kilu.He 2023-07-19 17:59:32 +08:00 committed by GitHub
parent 0fb004aee0
commit 5ab64f8835
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1085 additions and 286 deletions

View File

@ -105,6 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"collab",
@ -1029,6 +1030,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"bytes",
@ -1046,6 +1048,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"bytes",
"collab-sync",
@ -1063,6 +1066,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"async-trait",
@ -1089,6 +1093,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"proc-macro2",
"quote",
@ -1100,6 +1105,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"collab",
@ -1118,6 +1124,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"chrono",
@ -1137,6 +1144,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"bincode",
"chrono",
@ -1156,6 +1164,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"async-trait",
@ -1189,6 +1198,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"bytes",
"collab",
@ -1869,6 +1879,7 @@ dependencies = [
"flowy-folder2",
"flowy-net",
"flowy-server",
"flowy-server-config",
"flowy-sqlite",
"flowy-task",
"flowy-user",
@ -2070,6 +2081,7 @@ dependencies = [
"flowy-document2",
"flowy-error",
"flowy-folder2",
"flowy-server-config",
"flowy-user",
"futures",
"futures-util",
@ -2092,6 +2104,14 @@ dependencies = [
"uuid",
]
[[package]]
name = "flowy-server-config"
version = "0.1.0"
dependencies = [
"flowy-error",
"serde",
]
[[package]]
name = "flowy-sqlite"
version = "0.1.0"
@ -2128,6 +2148,8 @@ version = "0.1.0"
dependencies = [
"appflowy-integrate",
"bytes",
"collab",
"collab-folder",
"diesel",
"diesel_derives",
"fancy-regex 0.11.0",
@ -2135,6 +2157,7 @@ dependencies = [
"flowy-derive",
"flowy-error",
"flowy-notification",
"flowy-server-config",
"flowy-sqlite",
"lazy_static",
"lib-dispatch",
@ -2151,6 +2174,7 @@ dependencies = [
"tokio",
"tracing",
"unicode-segmentation",
"uuid",
"validator",
]

View File

@ -34,12 +34,12 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
#collab = { path = "../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }

View File

@ -0,0 +1,121 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { blockDraggableActions, DraggableContext, DragInsertType } from '$app_reducers/block-draggable/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { collisionNode, getDragDropContext, scrollIntoViewIfNeeded } from '$app/utils/draggable';
import { onDragEndThunk } from '$app_reducers/block-draggable/async_actions';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import { blockConfig } from '$app/constants/document/config';
function BlockDragDropContext({ children }: { children: React.ReactNode }) {
const shadowRef = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const { dragging, draggingId, dragShadowVisible, draggingPosition } = useAppSelector((state) => state.blockDraggable);
const registerDraggableEvents = useCallback(
(id: string) => {
const onDrag = (event: MouseEvent) => {
const data = collisionNode(event, id);
let dropContext: DraggableContext | undefined;
const dropId = data?.id;
let insertType = data?.insertType;
if (dropId) {
const context = getDragDropContext(dropId);
const contextId = context?.contextId;
const container = context?.container;
if (container) {
dropContext = {
type: context.type,
contextId: context.contextId,
};
scrollIntoViewIfNeeded(event, container as HTMLDivElement);
}
if (contextId) {
const block = getBlock(contextId, dropId);
if (block) {
const config = blockConfig[block.type];
if (!config.canAddChild && insertType === DragInsertType.CHILD) {
insertType = DragInsertType.AFTER;
}
}
}
}
dispatch(
blockDraggableActions.drag({
draggingPosition: {
x: event.clientX,
y: event.clientY,
},
insertType,
dropId,
dropContext,
})
);
};
const unlisten = () => {
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', onDragEnd);
};
const onDragEnd = () => {
dispatch(onDragEndThunk());
unlisten();
};
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', onDragEnd);
return unlisten;
},
[dispatch]
);
useEffect(() => {
if (!dragging || !draggingId) return;
return registerDraggableEvents(draggingId);
}, [dragging, draggingId, registerDraggableEvents]);
useEffect(() => {
if (!shadowRef.current) return;
if (!dragShadowVisible) {
shadowRef.current.innerHTML = '';
return;
}
const shadow = shadowRef.current;
const draggingNode = document.querySelector(`[data-draggable-id="${draggingId}"]`);
if (!draggingNode) return;
const clone = draggingNode.cloneNode(true);
shadow.appendChild(clone);
}, [dragShadowVisible, draggingId]);
return (
<>
{children}
<div
ref={shadowRef}
style={{
position: 'fixed',
top: draggingPosition?.y,
left: draggingPosition?.x,
pointerEvents: 'none',
opacity: dragShadowVisible ? 1 : 0,
zIndex: 1000,
width: '100%',
}}
/>
</>
);
}
export default BlockDragDropContext;

View File

@ -0,0 +1,82 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { blockDraggableActions, BlockDraggableType, DragInsertType } from '$app_reducers/block-draggable/slice';
import { getDragDropContext } from '$app/utils/draggable';
export function useDraggableState(id: string, type: BlockDraggableType) {
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const { dropState, isDragging } = useAppSelector((state) => {
const draggableState = state.blockDraggable;
const isDragging = draggableState.dragging && draggableState.draggingId === id;
if (draggableState.dropId === id) {
return {
dropState: {
dropId: draggableState.dropId,
insertType: draggableState.insertType,
},
isDragging,
};
}
return {
dropState: null,
isDragging,
};
});
const onDragStart = useCallback(
(event: React.MouseEvent | MouseEvent) => {
if (!ref.current) return;
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const { clientY: y, clientX: x } = event;
const context = getDragDropContext(id);
if (!context) return;
dispatch(
blockDraggableActions.startDrag({
startDraggingPosition: {
x,
y,
},
draggingId: id,
draggingContext: {
type,
contextId: context.contextId,
},
})
);
},
[dispatch, id, type]
);
const beforeDropping = useMemo(() => {
if (!dropState) return false;
return dropState.insertType === DragInsertType.BEFORE;
}, [dropState]);
const afterDropping = useMemo(() => {
if (!dropState) return false;
return dropState.insertType === DragInsertType.AFTER;
}, [dropState]);
const childDropping = useMemo(() => {
if (!dropState) return false;
return dropState.insertType === DragInsertType.CHILD;
}, [dropState]);
return {
onDragStart,
ref,
beforeDropping,
afterDropping,
childDropping,
isDragging,
};
}

View File

@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { useDraggableState } from '$app/components/_shared/BlockDraggable/BlockDraggable.hooks';
import { BlockDraggableType } from '$app_reducers/block-draggable/slice';
function BlockDraggable({
id,
type,
children,
getAnchorEl,
}: {
id: string;
type: BlockDraggableType;
children: React.ReactNode;
getAnchorEl?: () => HTMLElement | null;
}) {
const { onDragStart, ref, beforeDropping, afterDropping, childDropping, isDragging } = useDraggableState(id, type);
const commonCls = 'pointer-events-none absolute z-10 w-[100%] bg-fill-hover transition-all duration-200';
useEffect(() => {
if (!getAnchorEl) return;
const el = getAnchorEl();
if (!el) return;
el.addEventListener('mousedown', onDragStart);
return () => {
el.removeEventListener('mousedown', onDragStart);
};
}, [getAnchorEl, onDragStart]);
return (
<>
<div
ref={ref}
data-draggable-id={id}
data-draggable-type={type}
onMouseDown={getAnchorEl ? undefined : onDragStart}
className={'relative'}
style={{
opacity: isDragging ? 0.7 : 1,
}}
>
{
<div
style={{
display: beforeDropping ? 'block' : 'none',
}}
className={`${commonCls} left-0 top-[-2px] h-[4px]`}
/>
}
{children}
{
<div
style={{
display: childDropping ? 'block' : 'none',
}}
className={`${commonCls} left-0 top-0 h-[100%] opacity-[0.3]`}
/>
}
{
<div
style={{
display: afterDropping ? 'block' : 'none',
}}
className={`${commonCls} bottom-[-2px] left-0 h-[4px]`}
/>
}
</div>
</>
);
}
export default React.memo(BlockDraggable);

View File

@ -7,19 +7,19 @@ import { useTranslation } from 'react-i18next';
interface Props {
open: boolean;
title: string;
caption: string;
subtitle: string;
onOk: () => Promise<void>;
onClose: () => void;
}
function ConfirmDialog({ open, title, caption, onOk, onClose }: Props) {
function ConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
const { t } = useTranslation();
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
<div className={'text-md m-2 font-bold'}>{title}</div>
<div className={'m-1 text-sm text-text-caption'}>{caption}</div>
<div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>
</DialogContent>
<DialogActions>
<Button variant={'outlined'} onClick={onClose}>

View File

@ -15,6 +15,7 @@ export const BoardSettingsPopup = ({
}) => {
const [settingsItems, setSettingsItems] = useState<IPopupItem[]>([]);
const { t } = useTranslation();
useEffect(() => {
setSettingsItems([
{
@ -23,7 +24,7 @@ export const BoardSettingsPopup = ({
<PropertiesSvg></PropertiesSvg>
</i>
),
title: t('grid.settings.Properties'),
title: t('grid.settings.properties'),
onClick: onFieldsClick,
},
{
@ -42,7 +43,7 @@ export const BoardSettingsPopup = ({
<PopupSelect
onOutsideClick={() => hidePopup()}
items={settingsItems}
className={'absolute top-full left-full z-10 text-xs'}
className={'absolute left-full top-full z-10 text-xs'}
></PopupSelect>
);
};

View File

@ -117,8 +117,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const handleMouseDown = useCallback(
(e: MouseEvent) => {
if (e.button !== 0) return;
const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime;
const isTextBox = (e.target as HTMLElement).closest(`[role="textbox"]`);
if (!isTextBox) return;
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
@ -144,7 +148,6 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
anchorRef.current = {
...anchor,
};
// set the anchor point and focus point
dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor }));
dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor }));

View File

@ -52,6 +52,7 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
const handleDragStart = useCallback(
(e: MouseEvent) => {
if (e.button !== 0) return;
if (isPointInBlock(e.target as HTMLElement)) {
return;
}

View File

@ -1,9 +1,12 @@
import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppSelector } from '@/appflowy_app/stores/store';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
import { getNode } from '$app/utils/document/node';
import { get } from '$app/utils/tool';
const headingBlockTopOffset: Record<number, number> = {
1: 6,
@ -11,66 +14,76 @@ const headingBlockTopOffset: Record<number, number> = {
3: 3,
};
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
const [nodeId, setHoverNodeId] = useState<string | null>(null);
const ref = useRef<HTMLDivElement | null>(null);
const dispatch = useAppDispatch();
const [style, setStyle] = useState<React.CSSProperties>({});
export function useBlockSideToolbar(id: string) {
const { docId } = useSubscribeDocument();
useEffect(() => {
const el = ref.current;
const isDragging = useAppSelector((state) => {
return (
get(state, [RECT_RANGE_NAME, docId, 'isDragging'], false) ||
get(state, [RANGE_NAME, docId, 'isDragging'], false) ||
get(state, ['blockDraggable', 'dragging'], false)
);
});
const ref = useRef<HTMLDivElement | null>(null);
const [opacity, setOpacity] = useState(0);
if (!el || !nodeId) return;
void (async () => {
const node = getBlock(docId, nodeId);
const topOffset = useMemo(() => {
const block = getBlock(docId, id);
if (!node) {
setStyle({
opacity: '0',
pointerEvents: 'none',
});
if (!block) return 0;
if (block.type === BlockType.HeadingBlock) {
return headingBlockTopOffset[(block.data as HeadingBlockData).level];
}
if (block.type === BlockType.DividerBlock) {
return -6;
}
return 0;
}, [docId, id]);
const onMouseMove = useCallback(
(e: Event) => {
if (isDragging) {
setOpacity(0);
return;
} else {
let top = 0;
if (node.type === BlockType.HeadingBlock) {
const nodeData = node.data as HeadingBlockData;
top = headingBlockTopOffset[nodeData.level];
}
if (node.type === BlockType.DividerBlock) {
top = -3;
}
setStyle({
opacity: '1',
pointerEvents: 'auto',
top: `${top}px`,
});
}
})();
}, [dispatch, docId, nodeId]);
const handleMouseMove = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e;
const id = getNodeIdByPoint(clientX, clientY);
const target = (e.target as HTMLElement).closest('[data-block-id]');
setHoverNodeId(id);
if (!target) return;
const targetId = target.getAttribute('data-block-id');
if (targetId !== id) {
setOpacity(0);
return;
}
setOpacity(1);
},
[id, isDragging]
);
const onMouseLeave = useCallback(() => {
setOpacity(0);
}, []);
useEffect(() => {
container.addEventListener('mousemove', handleMouseMove);
const node = getNode(id);
if (!node) return;
node.addEventListener('mousemove', onMouseMove);
node.addEventListener('mouseleave', onMouseLeave);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
node.removeEventListener('mousemove', onMouseMove);
node.removeEventListener('mouseleave', onMouseLeave);
};
}, [container, handleMouseMove]);
}, [id, onMouseMove, onMouseLeave]);
return {
nodeId,
ref,
style,
opacity,
topOffset,
};
}

View File

@ -1,24 +0,0 @@
import React from 'react';
const sx = { height: 24, width: 24 };
import { IconButton } from '@mui/material';
import Tooltip from '@mui/material/Tooltip';
const ToolbarButton = ({
onClick,
children,
tooltip,
}: {
tooltip: string;
children: React.ReactNode;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return (
<Tooltip title={tooltip} placement={'top-start'}>
<IconButton onClick={onClick} sx={sx}>
{children}
</IconButton>
</Tooltip>
);
};
export default ToolbarButton;

View File

@ -1,85 +1,96 @@
import React from 'react';
import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
import Portal from '../BlockPortal';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useAppDispatch } from '$app/stores/store';
import Popover from '@mui/material/Popover';
import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
import AddSharpIcon from '@mui/icons-material/AddSharp';
import BlockMenu from './BlockMenu';
import ToolbarButton from './ToolbarButton';
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
import { useTranslation } from 'react-i18next';
import { IconButton } from '@mui/material';
import Tooltip from '@mui/material/Tooltip';
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
export default function BlockSideToolbar({ id }: { id: string }) {
const dispatch = useAppDispatch();
const { docId, controller } = useSubscribeDocument();
const { t } = useTranslation();
const { nodeId, style, ref } = useBlockSideToolbar({ container });
const isDragging = useAppSelector(
(state) => state[RANGE_NAME][docId]?.isDragging || state[RECT_RANGE_NAME][docId]?.isDragging
);
const { handleOpen, ...popoverProps } = usePopover();
const { handleOpen, open, ...popoverProps } = usePopover();
const { opacity, topOffset } = useBlockSideToolbar(id);
if (!nodeId || isDragging) return null;
const show = opacity === 1 || open;
return (
<>
<Portal blockId={nodeId}>
<div
ref={ref}
style={{
opacity: 0,
...style,
}}
className='absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
>
{/** Add Block below */}
<ToolbarButton
tooltip={t('tooltip.addBlockBelow')}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
if (!nodeId || !controller) return;
<div
style={{
opacity: show ? 1 : 0,
top: topOffset,
}}
className='absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-100'
>
{/** Add Block below */}
<Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')} placement={'top-start'}>
<IconButton
style={{
pointerEvents: show ? 'auto' : 'none',
}}
onClick={(_: React.MouseEvent<HTMLButtonElement>) => {
dispatch(
addBlockBelowClickThunk({
id: nodeId,
id,
controller,
})
);
}}
sx={{
height: 24,
width: 24,
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<AddSharpIcon />
</ToolbarButton>
</IconButton>
</Tooltip>
{/** Open menu or drag */}
<ToolbarButton
tooltip={t('tooltip.openMenu')}
{/** Open menu or drag */}
<Tooltip disableInteractive={true} title={t('blockActions.dragAndOpenTooltip')} placement={'top-start'}>
<IconButton
style={{
pointerEvents: show ? 'auto' : 'none',
}}
data-draggable-anchor={id}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
if (!nodeId) return;
dispatch(
setRectSelectionThunk({
docId,
selection: [nodeId],
selection: [id],
})
);
handleOpen(e);
}}
sx={{
height: 24,
width: 24,
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DragIndicatorRoundedIcon />
</ToolbarButton>
</div>
</Portal>
</IconButton>
</Tooltip>
</div>
<Popover {...popoverProps}>
<BlockMenu id={nodeId} onClose={popoverProps.onClose} />
<Popover open={open} {...popoverProps}>
<BlockMenu id={id} onClose={popoverProps.onClose} />
</Popover>
</>
);

View File

@ -1,7 +1,7 @@
export default function DividerBlock() {
return (
<div className={`flex h-[1em] w-[100%] items-center justify-center`}>
<div className={'h-[1px] w-[100%] bg-line-border'} />
<div className={'h-[1px] w-[100%] bg-line-divider'} />
</div>
);
}

View File

@ -20,6 +20,8 @@ import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.ho
import EquationBlock from '$app/components/document/EquationBlock';
import ImageBlock from '$app/components/document/ImageBlock';
import { useTranslation } from 'react-i18next';
import BlockDraggable from '$app/components/_shared/BlockDraggable';
import { BlockDraggableType } from '$app_reducers/block-draggable/slice';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
const { node, childIds, isSelected, ref } = useNode(id);
@ -79,13 +81,21 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
return (
<NodeIdContext.Provider value={id}>
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
{renderBlock()}
<BlockOverlay id={id} />
{isSelected ? (
<div className='pointer-events-none absolute inset-0 z-[-1] my-[1px] rounded-[4px] bg-content-blue-100' />
) : null}
</div>
<BlockDraggable
id={id}
type={BlockDraggableType.BLOCK}
getAnchorEl={() => {
return ref.current?.querySelector(`[data-draggable-anchor="${id}"]`) || null;
}}
>
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
{renderBlock()}
<BlockOverlay id={id} />
{isSelected ? (
<div className='pointer-events-none absolute inset-0 z-[-1] my-[1px] rounded-[4px] bg-content-blue-100' />
) : null}
</div>
</BlockDraggable>
</NodeIdContext.Provider>
);
}

View File

@ -1,7 +1,12 @@
import React from 'react';
import BlockSideToolbar from '$app/components/document/BlockSideToolbar';
function BlockOverlay({ id }: { id: string }) {
return <div className='block-overlay' />;
return (
<div className='block-overlay'>
<BlockSideToolbar id={id} />
</div>
);
}
export default BlockOverlay;

View File

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

View File

@ -4,6 +4,8 @@ import DocumentTitle from '../DocumentTitle';
import Overlay from '../Overlay';
import { Node } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
export default function VirtualizedList({
childIds,
node,
@ -16,10 +18,13 @@ export default function VirtualizedList({
const { virtualize, parentRef } = useVirtualizedList(childIds.length);
const virtualItems = virtualize.getVirtualItems();
const { docId } = useSubscribeDocument();
return (
<>
<div
ref={parentRef}
id={`appflowy-scroller_${docId}`}
className={`doc-scroller-container flex h-[100%] flex-wrap justify-center overflow-auto px-20`}
>
<div
@ -42,6 +47,7 @@ export default function VirtualizedList({
>
{virtualItems.map((virtualRow) => {
const id = childIds[virtualRow.index];
return (
<div
className='mt-[-0.5px] pt-[0.5px]'

View File

@ -45,7 +45,7 @@ export function useSubscribeNode(id: string) {
}
export function getBlock(docId: string, id: string) {
return store.getState().document[docId].nodes[id];
return store.getState().document[docId]?.nodes[id];
}
export const NodeIdContext = createContext<string>('');

View File

@ -3,6 +3,7 @@ import SideBar from '$app/components/layout/SideBar';
import TopBar from '$app/components/layout/TopBar';
import { useAppSelector } from '$app/stores/store';
import { FooterPanel } from '$app/components/layout/FooterPanel';
import BlockDragDropContext from '$app/components/_shared/BlockDraggable/BlockDragDropContext';
function Layout({ children }: { children: ReactNode }) {
const { isCollapsed, width } = useAppSelector((state) => state.sidebar);
@ -20,27 +21,29 @@ function Layout({ children }: { children: ReactNode }) {
};
}, []);
return (
<div className='flex h-screen w-[100%] text-sm text-text-title'>
<SideBar />
<div
className='flex flex-1 flex-col bg-bg-body'
style={{
width: isCollapsed ? 'auto' : `calc(100% - ${width}px)`,
}}
>
<TopBar />
<BlockDragDropContext>
<div className='flex h-screen w-[100%] text-sm text-text-title'>
<SideBar />
<div
className='flex flex-1 flex-col bg-bg-body'
style={{
height: 'calc(100vh - 64px - 48px)',
width: isCollapsed ? 'auto' : `calc(100% - ${width}px)`,
}}
className={'overflow-y-auto overflow-x-hidden'}
>
{children}
</div>
<TopBar />
<div
style={{
height: 'calc(100vh - 64px - 48px)',
}}
className={'overflow-y-auto overflow-x-hidden'}
>
{children}
</div>
<FooterPanel />
<FooterPanel />
</div>
</div>
</div>
</BlockDragDropContext>
);
}

View File

@ -1,11 +1,7 @@
import React, { useState } from 'react';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import Dialog from '@mui/material/Dialog';
import React from 'react';
import { useTranslation } from 'react-i18next';
import TextField from '@mui/material/TextField';
import { Button, DialogActions } from '@mui/material';
import { ViewLayoutPB } from '@/services/backend';
import ConfirmDialog from '$app/components/_shared/app-dialog/ConfirmDialog';
function DeleteDialog({
layout,
@ -28,36 +24,17 @@ function DeleteDialog({
}[layout];
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
<div className={'text-md m-2 font-bold'}>
{t('views.deleteContentTitle', {
pageType,
})}
</div>
<div className={'m-1 text-sm text-text-caption'}>
{t('views.deleteContentCaption', {
pageType,
})}
</div>
</DialogContent>
<DialogActions>
<Button variant={'outlined'} onClick={onClose}>
{t('button.Cancel')}
</Button>
<Button
variant={'contained'}
onClick={async () => {
try {
await onOk();
onClose();
} catch (e) {}
}}
>
{t('button.delete')}
</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={open}
title={t('views.deleteContentTitle', {
pageType,
})}
subtitle={t('views.deleteContentCaption', {
pageType,
})}
onOk={onOk}
onClose={onClose}
/>
);
}

View File

@ -9,9 +9,9 @@ import { useTranslation } from 'react-i18next';
export function useLoadChildPages(pageId: string) {
const dispatch = useAppDispatch();
const childPages = useAppSelector((state) => state.pages.childPages[pageId]);
const childPages = useAppSelector((state) => state.pages.relationMap[pageId]);
const collapsed = useAppSelector((state) => !state.pages.expandedPages[pageId]);
const collapsed = useAppSelector((state) => !state.pages.expandedIdMap[pageId]);
const toggleCollapsed = useCallback(() => {
if (collapsed) {
dispatch(pagesActions.expandPage(pageId));
@ -77,7 +77,7 @@ export function useLoadChildPages(pageId: string) {
}
export function usePageActions(pageId: string) {
const page = useAppSelector((state) => state.pages.map[pageId]);
const page = useAppSelector((state) => state.pages.pageMap[pageId]);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();

View File

@ -27,7 +27,7 @@ function NestedPageTitle({
onRename: (newName: string) => Promise<void>;
}) {
const page = useAppSelector((state) => {
return state.pages.map[pageId];
return state.pages.pageMap[pageId];
});
const [isHovering, setIsHovering] = useState(false);
const isSelected = useSelectedPage(pageId);

View File

@ -1,15 +1,17 @@
import React from 'react';
import React, { useEffect } from 'react';
import Collapse from '@mui/material/Collapse';
import { TransitionGroup } from 'react-transition-group';
import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle';
import { useLoadChildPages, usePageActions } from '$app/components/layout/NestedPage/NestedPage.hooks';
import BlockDraggable from '$app/components/_shared/BlockDraggable';
import { BlockDraggableType } from '$app_reducers/block-draggable/slice';
function NestedPage({ pageId }: { pageId: string }) {
const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
return (
<div>
<BlockDraggable id={pageId} type={BlockDraggableType.PAGE}>
<NestedPageTitle
onClick={() => {
onPageClick();
@ -32,8 +34,8 @@ function NestedPage({ pageId }: { pageId: string }) {
))}
</TransitionGroup>
</div>
</div>
</BlockDraggable>
);
}
export default NestedPage;
export default React.memo(NestedPage);

View File

@ -1,15 +1,17 @@
import React from 'react';
import React, { useRef } from 'react';
import { useAppSelector } from '$app/stores/store';
import NestedPage from '$app/components/layout/NestedPage';
import { List } from '@mui/material';
function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) {
const pageIds = useAppSelector((state) => {
return state.pages.childPages[workspaceId];
return state.pages.relationMap[workspaceId];
});
const ref = useRef(null);
return (
<List className={'h-[100%] overflow-y-auto overflow-x-hidden'}>
<List id={`appflowy-scroller_${workspaceId}`} ref={ref} className={'h-[100%] overflow-y-auto overflow-x-hidden'}>
{pageIds?.map((pageId) => (
<NestedPage key={pageId} pageId={pageId} />
))}

View File

@ -14,6 +14,7 @@ function TrashButton() {
return (
<MenuItem
data-page-id={'trash'}
selected={currentPathType === 'trash'}
onClick={navigateToTrash}
style={{

View File

@ -20,26 +20,34 @@ export function useLoadWorkspaces() {
return new WorkspaceManagerController();
}, []);
const initializeWorkspaces = useCallback(async () => {
const workspaces = await controller.getWorkspaces();
const currentWorkspace = await controller.getCurrentWorkspace();
dispatch(
workspaceActions.initWorkspaces({
workspaces,
currentWorkspace,
})
);
}, [controller, dispatch]);
const subscribeToWorkspaces = useCallback(async () => {
await controller.subscribe({
onWorkspacesChanged,
});
}, [controller, onWorkspacesChanged]);
useEffect(() => {
void (async () => {
const workspaces = await controller.getWorkspaces();
const currentWorkspace = await controller.getCurrentWorkspace();
await controller.subscribe({
onWorkspacesChanged,
});
dispatch(
workspaceActions.initWorkspaces({
workspaces,
currentWorkspace,
})
);
await initializeWorkspaces();
await subscribeToWorkspaces();
})();
return () => {
controller.dispose();
};
}, [controller, dispatch, onWorkspacesChanged]);
}, [controller, initializeWorkspaces, subscribeToWorkspaces]);
return {
workspaces,
@ -86,27 +94,35 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
[dispatch, id]
);
const initializeWorkspace = useCallback(async () => {
const childPages = await controller.getChildPages();
dispatch(
pagesActions.addChildPages({
id,
childPages,
})
);
}, [controller, dispatch, id]);
const subscribeToWorkspace = useCallback(async () => {
await controller.subscribe({
onWorkspaceChanged,
onWorkspaceDeleted,
onChildPagesChanged,
});
}, [controller, onChildPagesChanged, onWorkspaceChanged, onWorkspaceDeleted]);
useEffect(() => {
void (async () => {
const childPages = await controller.getChildPages();
dispatch(
pagesActions.addChildPages({
id,
childPages,
})
);
await controller.subscribe({
onWorkspaceChanged,
onWorkspaceDeleted,
onChildPagesChanged,
});
await initializeWorkspace();
await subscribeToWorkspace();
})();
return () => {
controller.dispose();
};
}, [controller, dispatch, id, onChildPagesChanged, onWorkspaceChanged, onWorkspaceDeleted]);
}, [controller, initializeWorkspace, subscribeToWorkspace]);
return {
openWorkspace,

View File

@ -8,10 +8,10 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo
const { openWorkspace, deleteWorkspace } = useLoadWorkspace(workspace);
return (
<div className={'flex flex-col'}>
<div className={'flex h-[100%] flex-col'}>
<div
style={{
height: opened ? 'auto' : 0,
height: opened ? '100%' : 0,
overflow: 'hidden',
transition: 'height 0.2s ease-in-out',
}}

View File

@ -10,7 +10,7 @@ function WorkspaceManager() {
return (
<div className={'flex h-[100%] flex-col justify-between'}>
<List className={'flex-1 overflow-y-auto overflow-x-hidden'}>
<List className={'flex-1 overflow-hidden'}>
{workspaces.map((workspace) => (
<Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} />
))}

View File

@ -1,28 +1,37 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TrashController } from '$app/stores/effects/workspace/trash/controller';
import { TrashPB } from '@/services/backend';
import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
import { trashActions, trashPBToTrash } from '$app_reducers/trash/slice';
export function useLoadTrash() {
const [trash, setTrash] = useState<TrashPB[]>([]);
const trash = useAppSelector((state) => state.trash.list);
const dispatch = useAppDispatch();
const controller = useMemo(() => {
return new TrashController();
}, []);
useEffect(() => {
void (async () => {
const trash = await controller.getTrash();
const initializeTrash = useCallback(async () => {
const trash = await controller.getTrash();
setTrash(trash);
})();
}, [controller]);
dispatch(trashActions.initTrash(trash.map(trashPBToTrash)));
}, [controller, dispatch]);
useEffect(() => {
const subscribeToTrash = useCallback(async () => {
controller.subscribe({
onTrashChanged: (trash) => {
setTrash(trash);
dispatch(trashActions.onTrashChanged(trash.map(trashPBToTrash)));
},
});
}, [controller, dispatch]);
useEffect(() => {
void (async () => {
await initializeTrash();
await subscribeToTrash();
})();
}, [initializeTrash, subscribeToTrash]);
useEffect(() => {
return () => {
controller.dispose();
};
@ -55,7 +64,7 @@ export function useTrashActions() {
setDeleteAllDialogOpen(true);
};
const closeDislog = () => {
const closeDialog = () => {
setRestoreAllDialogOpen(false);
setDeleteAllDialogOpen(false);
};
@ -77,6 +86,6 @@ export function useTrashActions() {
onClickDeleteAll,
restoreAllDialogOpen,
deleteAllDialogOpen,
closeDislog,
closeDialog,
};
}

View File

@ -5,7 +5,7 @@ import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
import { useLoadTrash, useTrashActions } from '$app/components/trash/Trash.hooks';
import { Divider, List } from '@mui/material';
import TrashItem from '$app/components/trash/TrashItem';
import ConfirmDialog from '$app/components/trash/ConfirmDialog';
import ConfirmDialog from '$app/components/_shared/app-dialog/ConfirmDialog';
function Trash() {
const { t } = useTranslation();
@ -19,7 +19,7 @@ function Trash() {
deleteAllDialogOpen,
onRestoreAll,
onDeleteAll,
closeDislog,
closeDialog,
} = useTrashActions();
const [hoverId, setHoverId] = useState('');
@ -60,16 +60,16 @@ function Trash() {
<ConfirmDialog
open={restoreAllDialogOpen}
title={t('trash.confirmRestoreAll.title')}
caption={t('trash.confirmRestoreAll.caption')}
subtitle={t('trash.confirmRestoreAll.caption')}
onOk={onRestoreAll}
onClose={closeDislog}
onClose={closeDialog}
/>
<ConfirmDialog
open={deleteAllDialogOpen}
title={t('trash.confirmDeleteAll.title')}
caption={t('trash.confirmDeleteAll.caption')}
subtitle={t('trash.confirmDeleteAll.caption')}
onOk={onDeleteAll}
onClose={closeDislog}
onClose={closeDialog}
/>
</div>
);

View File

@ -2,20 +2,19 @@ import React from 'react';
import dayjs from 'dayjs';
import { IconButton, ListItem } from '@mui/material';
import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
import { TrashPB } from '@/services/backend';
import Tooltip from '@mui/material/Tooltip';
import { useTranslation } from 'react-i18next';
import { Trash } from '$app_reducers/trash/slice';
function TrashItem({
item,
hoverId,
setHoverId,
onDelete,
onPutback,
}: {
setHoverId: (id: string) => void;
item: TrashPB;
item: Trash;
hoverId: string;
onPutback: (id: string) => void;
onDelete: (ids: string[]) => void;
@ -37,8 +36,8 @@ function TrashItem({
>
<div className={'flex w-[100%] items-center justify-around rounded-lg px-2 py-3 hover:bg-fill-list-hover'}>
<div className={'w-[40%] text-left'}>{item.name}</div>
<div className={'flex-1'}>{dayjs.unix(item.modified_time).format('MM/DD/YYYY hh:mm A')}</div>
<div className={'flex-1'}>{dayjs.unix(item.create_time).format('MM/DD/YYYY hh:mm A')}</div>
<div className={'flex-1'}>{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}</div>
<div className={'flex-1'}>{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}</div>
<div
style={{
visibility: hoverId === item.id ? 'visible' : 'hidden',
@ -46,12 +45,12 @@ function TrashItem({
className={'w-[64px]'}
>
<Tooltip placement={'top-start'} title={t('button.putback')}>
<IconButton onClick={(e) => onPutback(item.id)} className={'mr-2'}>
<IconButton onClick={(_) => onPutback(item.id)} className={'mr-2'}>
<RestoreOutlined />
</IconButton>
</Tooltip>
<Tooltip placement={'top-start'} title={t('button.delete')}>
<IconButton color={'error'} onClick={(e) => onDelete([item.id])}>
<IconButton color={'error'} onClick={(_) => onDelete([item.id])}>
<DeleteOutline />
</IconButton>
</Tooltip>

View File

@ -6,12 +6,14 @@ import {
FolderEventDuplicateView,
FolderEventCloseView,
FolderEventImportData,
FolderEventMoveView,
ViewIdPB,
CreateViewPayloadPB,
UpdateViewPayloadPB,
RepeatedViewIdPB,
ViewPB,
ImportPB,
MoveViewPayloadPB,
} from '@/services/backend/events/flowy-folder2';
import { Page } from '$app_reducers/pages/slice';
@ -28,6 +30,19 @@ export class PageBackendService {
return FolderEventReadView(payload);
};
movePage = async (params: { viewId: string; parentId: string; prevId?: string }) => {
console.log('movePage', params);
const payload = new MoveViewPayloadPB({
view_id: params.viewId,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
parent_view_id: params.parentId,
prev_view_id: params.prevId,
});
return FolderEventMoveView(payload);
};
createPage = async (params: ReturnType<typeof CreateViewPayloadPB.prototype.toObject>) => {
const payload = CreateViewPayloadPB.fromObject(params);

View File

@ -1,4 +1,4 @@
import { CreateViewPayloadPB, UpdateViewPayloadPB, ViewLayoutPB } from '@/services/backend';
import { ViewLayoutPB } from '@/services/backend';
import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
@ -31,6 +31,20 @@ export class PageController {
return Promise.reject(result.err);
};
movePage = async (params: { parentId: string; prevId?: string }): Promise<void> => {
const result = await this.backendService.movePage({
viewId: this.id,
parentId: params.parentId,
prevId: params.prevId,
});
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
getChildPages = async (): Promise<Page[]> => {
const result = await this.backendService.getPage(this.id);

View File

@ -0,0 +1,53 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { blockDraggableActions, BlockDraggableType } from '$app_reducers/block-draggable/slice';
import { dragThunk } from '$app_reducers/document/async-actions/drag';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { movePageThunk } from '$app_reducers/pages/async_actions';
import { Log } from '$app/utils/log';
export const onDragEndThunk = createAsyncThunk('blockDraggable/onDragEnd', async (payload: void, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const { dragging, draggingId, dropId, insertType, draggingContext, dropContext } = (getState() as RootState)
.blockDraggable;
if (!dragging) return;
dispatch(blockDraggableActions.endDrag());
if (!draggingId || !dropId || !insertType || !draggingContext || !dropContext) return;
if (draggingContext.type !== dropContext.type) {
// TODO: will support this in the future
Log.info('Unsupported drag this block to different type of block');
return;
}
if (dropContext.type === BlockDraggableType.BLOCK) {
const docId = dropContext.contextId;
if (!docId) return;
await dispatch(
dragThunk({
draggingId,
dropId,
insertType,
controller: new DocumentController(docId),
})
);
return;
}
if (dropContext.type === BlockDraggableType.PAGE) {
const workspaceId = dropContext.contextId;
if (!workspaceId) return;
await dispatch(
movePageThunk({
sourceId: draggingId,
targetId: dropId,
insertType,
})
);
return;
}
});

View File

@ -0,0 +1,100 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
const DRAG_DISTANCE_THRESHOLD = 10;
export enum BlockDraggableType {
BLOCK = 'BLOCK',
PAGE = 'PAGE',
}
export interface DraggableContext {
type: BlockDraggableType;
contextId?: string;
}
export interface BlockDraggableState {
dragging: boolean;
startDraggingPosition?: {
x: number;
y: number;
};
draggingPosition?: {
x: number;
y: number;
};
isDraggable: boolean;
dragShadowVisible: boolean;
draggingId?: string;
insertType?: DragInsertType;
dropId?: string;
dropContext?: DraggableContext;
draggingContext?: DraggableContext;
}
export enum DragInsertType {
BEFORE = 'BEFORE',
AFTER = 'AFTER',
CHILD = 'CHILD',
}
const initialState: BlockDraggableState = {
dragging: false,
isDraggable: true,
dragShadowVisible: false,
};
export const blockDraggableSlice = createSlice({
name: 'blockDraggable',
initialState: initialState,
reducers: {
startDrag: (
state,
action: PayloadAction<{
startDraggingPosition: {
x: number;
y: number;
};
draggingId: string;
draggingContext: DraggableContext;
}>
) => {
const { draggingContext, startDraggingPosition, draggingId } = action.payload;
state.dragging = true;
state.startDraggingPosition = startDraggingPosition;
state.draggingId = draggingId;
state.draggingContext = draggingContext;
},
drag: (
state,
action: PayloadAction<{
draggingPosition: {
x: number;
y: number;
};
insertType?: DragInsertType;
dropId?: string;
dropContext?: DraggableContext;
}>
) => {
const { dropContext, dropId, draggingPosition, insertType } = action.payload;
state.draggingPosition = draggingPosition;
state.dropContext = dropContext;
const moveDistance = Math.sqrt(
Math.pow(draggingPosition.x - state.startDraggingPosition!.x, 2) +
Math.pow(draggingPosition.y - state.startDraggingPosition!.y, 2)
);
state.dropId = dropId;
state.insertType = insertType;
state.dragShadowVisible = moveDistance > DRAG_DISTANCE_THRESHOLD;
},
endDrag: (state) => {
return initialState;
},
},
});
export const blockDraggableActions = blockDraggableSlice.actions;

View File

@ -0,0 +1,54 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { DragInsertType } from '$app_reducers/block-draggable/slice';
import { DocumentController } from '$app/stores/effects/document/document_controller';
export const dragThunk = createAsyncThunk(
'document/drag',
async (
payload: {
draggingId: string;
dropId: string;
insertType: DragInsertType;
controller: DocumentController;
},
thunkAPI
) => {
const { getState } = thunkAPI;
const { draggingId, dropId, insertType, controller } = payload;
const docId = controller.documentId;
const documentState = (getState() as RootState).document[docId];
const { nodes, children } = documentState;
const draggingNode = nodes[draggingId];
const targetNode = nodes[dropId];
const targetChildren = children[targetNode.children] || [];
const targetParentId = targetNode.parent;
if (!targetParentId) return;
const targetParent = nodes[targetParentId];
const targetParentChildren = children[targetParent.children] || [];
let prevId, parentId;
if (insertType === DragInsertType.BEFORE) {
const targetIndex = targetParentChildren.indexOf(dropId);
const prevIndex = targetIndex - 1;
parentId = targetParentId;
if (prevIndex >= 0) {
prevId = targetParentChildren[prevIndex];
}
} else if (insertType === DragInsertType.AFTER) {
prevId = dropId;
parentId = targetParentId;
} else {
parentId = dropId;
if (targetChildren.length > 0) {
prevId = targetChildren[targetChildren.length - 1];
}
}
const actions = [controller.getMoveAction(draggingNode, parentId, prevId || null)];
await controller.applyActions(actions);
}
);

View File

@ -0,0 +1,58 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { DragInsertType } from '$app_reducers/block-draggable/slice';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
export const movePageThunk = createAsyncThunk(
'pages/movePage',
async (
payload: {
sourceId: string;
targetId: string;
insertType: DragInsertType;
},
thunkAPI
) => {
const { sourceId, targetId, insertType } = payload;
const { getState } = thunkAPI;
const { pageMap, relationMap } = (getState() as RootState).pages;
const sourcePage = pageMap[sourceId];
const targetPage = pageMap[targetId];
if (!sourcePage || !targetPage) return;
const sourceParentId = sourcePage.parentId;
const targetParentId = targetPage.parentId;
if (!sourceParentId || !targetParentId) return;
const targetParentChildren = relationMap[targetParentId] || [];
const targetIndex = targetParentChildren.indexOf(targetId);
if (targetIndex < 0) return;
let prevId, parentId;
if (insertType === DragInsertType.BEFORE) {
const prevIndex = targetIndex - 1;
parentId = targetParentId;
if (prevIndex >= 0) {
prevId = targetParentChildren[prevIndex];
}
} else if (insertType === DragInsertType.AFTER) {
prevId = targetId;
parentId = targetParentId;
} else {
const targetChildren = relationMap[targetId] || [];
parentId = targetId;
if (targetChildren.length > 0) {
prevId = targetChildren[targetChildren.length - 1];
}
}
const controller = new PageController(sourceId);
await controller.movePage({ parentId, prevId });
}
);

View File

@ -22,15 +22,15 @@ export function parserViewPBToPage(view: ViewPB) {
}
export interface PageState {
map: Record<string, Page>;
childPages: Record<string, string[]>;
expandedPages: Record<string, boolean>;
pageMap: Record<string, Page>;
relationMap: Record<string, string[] | undefined>;
expandedIdMap: Record<string, boolean>;
}
export const initialState: PageState = {
map: {},
childPages: {},
expandedPages: {},
pageMap: {},
relationMap: {},
expandedIdMap: {},
};
export const pagesSlice = createSlice({
@ -54,29 +54,29 @@ export const pagesSlice = createSlice({
children.push(page.id);
});
state.map = {
...state.map,
state.pageMap = {
...state.pageMap,
...pageMap,
};
state.childPages[id] = children;
state.relationMap[id] = children;
},
removeChildPages(state, action: PayloadAction<string>) {
const parentId = action.payload;
delete state.childPages[parentId];
delete state.relationMap[parentId];
},
expandPage(state, action: PayloadAction<string>) {
const id = action.payload;
state.expandedPages[id] = true;
state.expandedIdMap[id] = true;
},
collapsePage(state, action: PayloadAction<string>) {
const id = action.payload;
state.expandedPages[id] = false;
state.expandedIdMap[id] = false;
},
},
});

View File

@ -0,0 +1,41 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TrashPB } from '@/services/backend';
export interface Trash {
id: string;
name: string;
modifiedTime: number;
createTime: number;
}
export function trashPBToTrash(trash: TrashPB) {
return {
id: trash.id,
name: trash.name,
modifiedTime: trash.modified_time,
createTime: trash.create_time,
};
}
interface TrashState {
list: Trash[];
}
const initialState: TrashState = {
list: [],
};
export const trashSlice = createSlice({
name: 'trash',
initialState,
reducers: {
initTrash: (state, action: PayloadAction<Trash[]>) => {
state.list = action.payload;
},
onTrashChanged: (state, action: PayloadAction<Trash[]>) => {
state.list = action.payload;
},
},
});
export const trashActions = trashSlice.actions;

View File

@ -16,6 +16,8 @@ import { documentReducers } from './reducers/document/slice';
import { boardSlice } from './reducers/board/slice';
import { errorSlice } from './reducers/error/slice';
import { sidebarSlice } from '$app_reducers/sidebar/slice';
import { blockDraggableSlice } from '$app_reducers/block-draggable/slice';
import { trashSlice } from '$app_reducers/trash/slice';
const listenerMiddlewareInstance = createListenerMiddleware({
onError: () => console.error,
@ -31,6 +33,8 @@ const store = configureStore({
[workspaceSlice.name]: workspaceSlice.reducer,
[errorSlice.name]: errorSlice.reducer,
[sidebarSlice.name]: sidebarSlice.reducer,
[blockDraggableSlice.name]: blockDraggableSlice.reducer,
[trashSlice.name]: trashSlice.reducer,
...documentReducers,
},
middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),

View File

@ -0,0 +1,113 @@
import { BlockDraggableType, DragInsertType } from '$app_reducers/block-draggable/slice';
import { findParent } from '$app/utils/document/node';
import { nanoid } from 'nanoid';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import { blockConfig } from '$app/constants/document/config';
export function getDraggableIdByPoint(target: HTMLElement | null) {
let node = target;
while (node) {
const id = node.getAttribute('data-draggable-id');
if (id) {
return id;
}
node = node.parentElement;
}
return null;
}
export function getDraggableNode(id: string) {
return document.querySelector(`[data-draggable-id="${id}"]`);
}
export function getDragDropContext(id: string) {
const node = getDraggableNode(id);
if (!node) return;
const type = node.getAttribute('data-draggable-type') as BlockDraggableType;
const container = node.closest('[id^=appflowy-scroller]');
if (!container) return;
const containerId = container.id;
const contextId = containerId.split('_')[1];
return {
contextId,
container,
type,
};
}
export function collisionNode(event: MouseEvent, draggingId: string) {
event.stopPropagation();
const { clientY, target, clientX } = event;
if (!target) return;
let id = getDraggableIdByPoint(target as HTMLElement);
if (!id) return;
if (id === draggingId) return;
const parentIsDraggingId = (target as HTMLElement).closest(`[data-draggable-id="${draggingId}"]`);
if (parentIsDraggingId) return;
const node = getDraggableNode(id);
if (!node) return;
const { top, bottom, left } = node.getBoundingClientRect();
let parent = node.parentElement;
let nodeLeft = left;
while (parent && clientX < nodeLeft) {
const parentNode = findParent(parent, '[data-draggable-id]');
if (!parentNode) break;
const parentId = parentNode.getAttribute('data-draggable-id');
id = parentId || id;
nodeLeft = parentNode.getBoundingClientRect().left;
parent = parentNode.parentElement;
}
let insertType = DragInsertType.CHILD;
if (clientY - top < 4) {
insertType = DragInsertType.BEFORE;
}
if (clientY > bottom - 4) {
insertType = DragInsertType.AFTER;
}
return {
id,
insertType,
};
}
const scrollThreshold = 20;
export function scrollIntoViewIfNeeded(e: MouseEvent, container: HTMLDivElement) {
const { top, bottom } = container.getBoundingClientRect();
let delta = 0;
if (e.clientY + scrollThreshold >= bottom) {
delta = e.clientY + scrollThreshold - bottom;
} else if (e.clientY - scrollThreshold <= top) {
delta = e.clientY - scrollThreshold - top;
}
container.scrollBy(0, delta);
}
export function generateDragContextId() {
return nanoid(10);
}

View File

@ -6,14 +6,17 @@ export function debounce(fn: (...args: any[]) => void, delay: number) {
fn.apply(undefined, args);
}, delay);
};
debounceFn.cancel = () => {
clearTimeout(timeout);
};
return debounceFn;
}
export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) {
let timeout: NodeJS.Timeout | null = null;
return (...args: any[]) => {
if (!timeout) {
timeout = setTimeout(() => {
@ -27,25 +30,31 @@ export function throttle(fn: (...args: any[]) => void, delay: number, immediate
export function get<T = any>(obj: any, path: string[], defaultValue?: any): T {
let value = obj;
for (const prop of path) {
value = value[prop];
if (value === undefined) {
if (value === undefined || typeof value !== 'object' || value[prop] === undefined) {
return defaultValue !== undefined ? defaultValue : undefined;
}
value = value[prop];
}
return value;
}
export function set(obj: any, path: string[], value: any): void {
let current = obj;
for (let i = 0; i < path.length; i++) {
const prop = path[i];
if (i === path.length - 1) {
current[prop] = value;
} else {
if (!current[prop]) {
current[prop] = {};
}
current = current[prop];
}
}
@ -84,6 +93,7 @@ export function isEqual<T>(value1: T, value2: T): boolean {
return false;
}
}
return true;
}
@ -97,8 +107,10 @@ export function clone<T>(value: T): T {
}
const result: any = {};
for (const key in value) {
result[key] = clone(value[key]);
}
return result;
}

View File

@ -7,7 +7,7 @@ export const BoardPage = () => {
const params = useParams();
const [viewId, setViewId] = useState('');
const pagesStore = useAppSelector((state) => state.pages);
const page = useAppSelector((state) => (params.id ? state.pages.map[params.id] : undefined));
const page = useAppSelector((state) => (params.id ? state.pages.pageMap[params.id] : undefined));
const [title, setTitle] = useState('');
useEffect(() => {

View File

@ -12,7 +12,8 @@
"addBelowTooltip": "Click to add below",
"addAboveCmd": "Alt+click",
"addAboveMacCmd": "Option+click",
"addAboveTooltip": "to add above"
"addAboveTooltip": "to add above",
"dragAndOpenTooltip": "Drag to reorder, click to open"
},
"signUp": {
"buttonText": "Sign Up",

View File

@ -85,7 +85,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"collab",
@ -897,7 +897,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"bytes",
@ -915,7 +915,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"bytes",
"collab-sync",
@ -933,7 +933,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"async-trait",
@ -960,7 +960,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"proc-macro2",
"quote",
@ -972,7 +972,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"collab",
@ -991,7 +991,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"chrono",
@ -1011,7 +1011,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"bincode",
"chrono",
@ -1031,7 +1031,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"anyhow",
"async-trait",
@ -1065,7 +1065,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2eb044#2eb044356ba49b26382c4aa9f1fd03d7663e84d3"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=52b550b#52b550b3dc3ff5969b92fea0c0a2b03530d73d20"
dependencies = [
"bytes",
"collab",

View File

@ -34,11 +34,11 @@ opt-level = 3
incremental = false
[patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2eb044" }
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "52b550b" }
#collab = { path = "../AppFlowy-Collab/collab" }
#collab-folder = { path = "../AppFlowy-Collab/collab-folder" }