From 5ab64f883559af247e867ca201bd303ce32d243c Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:59:32 +0800 Subject: [PATCH] feat: support views drag and drop (#3004) --- frontend/appflowy_tauri/src-tauri/Cargo.lock | 24 ++++ frontend/appflowy_tauri/src-tauri/Cargo.toml | 12 +- .../BlockDraggable/BlockDragDropContext.tsx | 121 ++++++++++++++++++ .../BlockDraggable/BlockDraggable.hooks.ts | 82 ++++++++++++ .../_shared/BlockDraggable/index.tsx | 73 +++++++++++ .../app-dialog}/ConfirmDialog.tsx | 6 +- .../components/board/BoardSettingsPopup.tsx | 5 +- .../BlockRangeSelection.hooks.ts | 5 +- .../BlockRectSelection.hooks.ts | 1 + .../BlockSideToolbar.hooks.tsx | 105 ++++++++------- .../BlockSideToolbar/ToolbarButton.tsx | 24 ---- .../document/BlockSideToolbar/index.tsx | 95 ++++++++------ .../document/DividerBlock/index.tsx | 2 +- .../components/document/Node/index.tsx | 24 +++- .../document/Overlay/BlockOverlay.tsx | 7 +- .../components/document/Overlay/index.tsx | 1 - .../document/VirtualizedList/index.tsx | 6 + .../document/_shared/SubscribeNode.hooks.ts | 2 +- .../appflowy_app/components/layout/Layout.tsx | 33 ++--- .../layout/NestedPage/DeleteDialog.tsx | 49 ++----- .../layout/NestedPage/NestedPage.hooks.ts | 6 +- .../layout/NestedPage/NestedPageTitle.tsx | 2 +- .../components/layout/NestedPage/index.tsx | 10 +- .../layout/WorkspaceManager/NestedPages.tsx | 8 +- .../layout/WorkspaceManager/TrashButton.tsx | 1 + .../WorkspaceManager/Workspace.hooks.ts | 70 ++++++---- .../layout/WorkspaceManager/Workspace.tsx | 4 +- .../layout/WorkspaceManager/index.tsx | 2 +- .../components/trash/Trash.hooks.ts | 37 ++++-- .../appflowy_app/components/trash/Trash.tsx | 12 +- .../components/trash/TrashItem.tsx | 13 +- .../effects/workspace/page/page_bd_svc.ts | 15 +++ .../effects/workspace/page/page_controller.ts | 16 ++- .../reducers/block-draggable/async_actions.ts | 53 ++++++++ .../stores/reducers/block-draggable/slice.ts | 100 +++++++++++++++ .../reducers/document/async-actions/drag.ts | 54 ++++++++ .../stores/reducers/pages/async_actions.ts | 58 +++++++++ .../stores/reducers/pages/slice.ts | 24 ++-- .../stores/reducers/trash/slice.ts | 41 ++++++ .../src/appflowy_app/stores/store.ts | 4 + .../src/appflowy_app/utils/draggable.ts | 113 ++++++++++++++++ .../src/appflowy_app/utils/tool.ts | 16 ++- .../src/appflowy_app/views/BoardPage.tsx | 2 +- frontend/resources/translations/en.json | 3 +- frontend/rust-lib/Cargo.lock | 20 +-- frontend/rust-lib/Cargo.toml | 10 +- 46 files changed, 1085 insertions(+), 286 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx rename frontend/appflowy_tauri/src/appflowy_app/components/{trash => _shared/app-dialog}/ConfirmDialog.tsx (86%) delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index a6911c7f3e..c7a230785a 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -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", ] diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index aec70946d2..4cd62039c8 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -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" } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx new file mode 100644 index 0000000000..4ecc4d60e3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx @@ -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(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} +
+ + ); +} + +export default BlockDragDropContext; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts new file mode 100644 index 0000000000..7eade71469 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts @@ -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(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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx new file mode 100644 index 0000000000..075f51e930 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx @@ -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 ( + <> +
+ { +
+ } + + {children} + { +
+ } + { +
+ } +
+ + ); +} + +export default React.memo(BlockDraggable); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/ConfirmDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/app-dialog/ConfirmDialog.tsx similarity index 86% rename from frontend/appflowy_tauri/src/appflowy_app/components/trash/ConfirmDialog.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/_shared/app-dialog/ConfirmDialog.tsx index cbaaa638b8..66f419ce5f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/ConfirmDialog.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/app-dialog/ConfirmDialog.tsx @@ -7,19 +7,19 @@ import { useTranslation } from 'react-i18next'; interface Props { open: boolean; title: string; - caption: string; + subtitle: string; onOk: () => Promise; onClose: () => void; } -function ConfirmDialog({ open, title, caption, onOk, onClose }: Props) { +function ConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { const { t } = useTranslation(); return ( e.stopPropagation()} open={open} onClose={onClose}>
{title}
-
{caption}
+
{subtitle}
- - -
+ ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts index eb8e4d0c28..df82d081cc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts @@ -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(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx index 6f5a69e121..9ea9d47ce5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx @@ -27,7 +27,7 @@ function NestedPageTitle({ onRename: (newName: string) => Promise; }) { const page = useAppSelector((state) => { - return state.pages.map[pageId]; + return state.pages.pageMap[pageId]; }); const [isHovering, setIsHovering] = useState(false); const isSelected = useSelectedPage(pageId); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx index e6bc4b13b6..e0c0ac225a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx @@ -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 ( -
+ { onPageClick(); @@ -32,8 +34,8 @@ function NestedPage({ pageId }: { pageId: string }) { ))}
-
+ ); } -export default NestedPage; +export default React.memo(NestedPage); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx index 3137a0678a..3930a51267 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx @@ -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 ( - + {pageIds?.map((pageId) => ( ))} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx index f1d1ca2209..a29abdce0d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx @@ -14,6 +14,7 @@ function TrashButton() { return ( { + 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, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx index 1231bfcc2d..bafea58b3a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx @@ -8,10 +8,10 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo const { openWorkspace, deleteWorkspace } = useLoadWorkspace(workspace); return ( -
+
- + {workspaces.map((workspace) => ( ))} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts index bd36bf8365..a95ae94a77 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts @@ -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([]); - + 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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx index e4edabf8c7..23a78a9bef 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx @@ -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() {
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx index 2e5aa7979b..78ce773423 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx @@ -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({ >
{item.name}
-
{dayjs.unix(item.modified_time).format('MM/DD/YYYY hh:mm A')}
-
{dayjs.unix(item.create_time).format('MM/DD/YYYY hh:mm A')}
+
{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}
+
{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}
- onPutback(item.id)} className={'mr-2'}> + onPutback(item.id)} className={'mr-2'}> - onDelete([item.id])}> + onDelete([item.id])}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts index 8ad5ef4ef8..b24d7c9e1a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts @@ -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) => { const payload = CreateViewPayloadPB.fromObject(params); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts index 88260b77f2..00152b114b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts @@ -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 => { + 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 => { const result = await this.backendService.getPage(this.id); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts new file mode 100644 index 0000000000..5e3f471bd6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts @@ -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; + } +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts new file mode 100644 index 0000000000..f5c9462095 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts @@ -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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts new file mode 100644 index 0000000000..3425c2cd50 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts @@ -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); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts new file mode 100644 index 0000000000..5320a13f4d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts @@ -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 }); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts index 02727f914e..6804620b58 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts @@ -22,15 +22,15 @@ export function parserViewPBToPage(view: ViewPB) { } export interface PageState { - map: Record; - childPages: Record; - expandedPages: Record; + pageMap: Record; + relationMap: Record; + expandedIdMap: Record; } 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) { const parentId = action.payload; - delete state.childPages[parentId]; + delete state.relationMap[parentId]; }, expandPage(state, action: PayloadAction) { const id = action.payload; - state.expandedPages[id] = true; + state.expandedIdMap[id] = true; }, collapsePage(state, action: PayloadAction) { const id = action.payload; - state.expandedPages[id] = false; + state.expandedIdMap[id] = false; }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts new file mode 100644 index 0000000000..98d850f6fe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts @@ -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) => { + state.list = action.payload; + }, + onTrashChanged: (state, action: PayloadAction) => { + state.list = action.payload; + }, + }, +}); + +export const trashActions = trashSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts index 1f160380cc..9a25c43099 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts @@ -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), diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts new file mode 100644 index 0000000000..e0769899ca --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts @@ -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); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts index 55d40e9b9c..3876e5f5c3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts @@ -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(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(value1: T, value2: T): boolean { return false; } } + return true; } @@ -97,8 +107,10 @@ export function clone(value: T): T { } const result: any = {}; + for (const key in value) { result[key] = clone(value[key]); } + return result; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx index 738d27f232..6066b00e7a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx @@ -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(() => { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 0ce22fb3a0..79d4cb4bf1 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 673d89cbc4..402b8231c7 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -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", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index c5ef7a8fba..f5fa4f56af 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -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" }