mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support views drag and drop (#3004)
This commit is contained in:
parent
0fb004aee0
commit
5ab64f8835
24
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
24
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
@ -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" }
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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);
|
@ -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}>
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 }));
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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} />
|
||||
|
@ -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]'
|
||||
|
@ -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>('');
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -14,6 +14,7 @@ function TrashButton() {
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
data-page-id={'trash'}
|
||||
selected={currentPathType === 'trash'}
|
||||
onClick={navigateToTrash}
|
||||
style={{
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
}}
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
});
|
@ -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;
|
@ -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);
|
||||
}
|
||||
);
|
@ -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 });
|
||||
}
|
||||
);
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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;
|
@ -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),
|
||||
|
113
frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts
Normal file
113
frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts
Normal 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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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",
|
||||
|
20
frontend/rust-lib/Cargo.lock
generated
20
frontend/rust-lib/Cargo.lock
generated
@ -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",
|
||||
|
@ -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" }
|
||||
|
Loading…
Reference in New Issue
Block a user