Forráskód Böngészése

feat: support views drag and drop (#3004)

Kilu.He 1 éve
szülő
commit
5ab64f8835
46 módosított fájl, 1082 hozzáadás és 283 törlés
  1. 24 0
      frontend/appflowy_tauri/src-tauri/Cargo.lock
  2. 6 6
      frontend/appflowy_tauri/src-tauri/Cargo.toml
  3. 121 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx
  4. 82 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts
  5. 73 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx
  6. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/app-dialog/ConfirmDialog.tsx
  7. 3 2
      frontend/appflowy_tauri/src/appflowy_app/components/board/BoardSettingsPopup.tsx
  8. 4 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
  9. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts
  10. 56 43
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  11. 0 24
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx
  12. 53 42
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  13. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/DividerBlock/index.tsx
  14. 17 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  15. 6 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx
  16. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  17. 6 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  18. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  19. 18 15
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx
  20. 13 36
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/DeleteDialog.tsx
  21. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts
  22. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx
  23. 6 4
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx
  24. 5 3
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx
  25. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx
  26. 43 27
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts
  27. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx
  28. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx
  29. 23 14
      frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts
  30. 6 6
      frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx
  31. 6 7
      frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx
  32. 15 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts
  33. 15 1
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts
  34. 53 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts
  35. 100 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts
  36. 54 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts
  37. 58 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts
  38. 12 12
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts
  39. 41 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts
  40. 4 0
      frontend/appflowy_tauri/src/appflowy_app/stores/store.ts
  41. 113 0
      frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts
  42. 14 2
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
  43. 1 1
      frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx
  44. 2 1
      frontend/resources/translations/en.json
  45. 10 10
      frontend/rust-lib/Cargo.lock
  46. 5 5
      frontend/rust-lib/Cargo.toml

+ 24 - 0
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",
 ]
 

+ 6 - 6
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" }

+ 121 - 0
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<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;

+ 82 - 0
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<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,
+  };
+}

+ 73 - 0
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 (
+    <>
+      <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);

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/trash/ConfirmDialog.tsx → 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<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}>

+ 3 - 2
frontend/appflowy_tauri/src/appflowy_app/components/board/BoardSettingsPopup.tsx

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

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts

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

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts

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

+ 56 - 43
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -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',
-        });
-        return;
-      } else {
-        let top = 0;
+    if (!block) return 0;
+    if (block.type === BlockType.HeadingBlock) {
+      return headingBlockTopOffset[(block.data as HeadingBlockData).level];
+    }
 
-        if (node.type === BlockType.HeadingBlock) {
-          const nodeData = node.data as HeadingBlockData;
+    if (block.type === BlockType.DividerBlock) {
+      return -6;
+    }
 
-          top = headingBlockTopOffset[nodeData.level];
-        }
+    return 0;
+  }, [docId, id]);
 
-        if (node.type === BlockType.DividerBlock) {
-          top = -3;
-        }
+  const onMouseMove = useCallback(
+    (e: Event) => {
+      if (isDragging) {
+        setOpacity(0);
+        return;
+      }
+
+      const target = (e.target as HTMLElement).closest('[data-block-id]');
 
-        setStyle({
-          opacity: '1',
-          pointerEvents: 'auto',
-          top: `${top}px`,
-        });
+      if (!target) return;
+      const targetId = target.getAttribute('data-block-id');
+
+      if (targetId !== id) {
+        setOpacity(0);
+        return;
       }
-    })();
-  }, [dispatch, docId, nodeId]);
 
-  const handleMouseMove = useCallback((e: MouseEvent) => {
-    const { clientX, clientY } = e;
-    const id = getNodeIdByPoint(clientX, clientY);
+      setOpacity(1);
+    },
+    [id, isDragging]
+  );
 
-    setHoverNodeId(id);
+  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,
   };
 }
 

+ 0 - 24
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx

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

+ 53 - 42
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx

@@ -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 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/DividerBlock/index.tsx

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

+ 17 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

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

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx

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

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx

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

+ 6 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx

@@ -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]'

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts

@@ -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>('');

+ 18 - 15
frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx

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

+ 13 - 36
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/DeleteDialog.tsx

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

+ 3 - 3
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();

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx

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

+ 6 - 4
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 (
-    <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);

+ 5 - 3
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 (
-    <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} />
       ))}

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx

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

+ 43 - 27
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts

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

+ 2 - 2
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 (
-    <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',
         }}

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx

@@ -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} />
         ))}

+ 23 - 14
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<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,
   };
 }

+ 6 - 6
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() {
       <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>
   );

+ 6 - 7
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({
     >
       <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>

+ 15 - 0
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<typeof CreateViewPayloadPB.prototype.toObject>) => {
     const payload = CreateViewPayloadPB.fromObject(params);
 

+ 15 - 1
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<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);
 

+ 53 - 0
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;
+  }
+});

+ 100 - 0
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;

+ 54 - 0
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);
+  }
+);

+ 58 - 0
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 });
+  }
+);

+ 12 - 12
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<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;
     },
   },
 });

+ 41 - 0
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<Trash[]>) => {
+      state.list = action.payload;
+    },
+    onTrashChanged: (state, action: PayloadAction<Trash[]>) => {
+      state.list = action.payload;
+    },
+  },
+});
+
+export const trashActions = trashSlice.actions;

+ 4 - 0
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),

+ 113 - 0
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);
+}

+ 14 - 2
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<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;
 }

+ 1 - 1
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(() => {

+ 2 - 1
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",

+ 10 - 10
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",

+ 5 - 5
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" }