Browse Source

feat: drag and drop a row in the Grid (#3376)

* feat: new style ui

* feat: drag row

* feat: hooks for auto scroll feature when dragging

* feat: shared UI status that control auto scroll when dragging row.

* feat: optimize the drag interaction UI effect

* feat: refactor drag positon

* feat: drag column header

* feat: fix review issue
fangwufeng-v 1 year ago
parent
commit
f70aef68be
34 changed files with 1062 additions and 287 deletions
  1. 1 1
      frontend/appflowy_tauri/.eslintrc.cjs
  2. 1 0
      frontend/appflowy_tauri/src-tauri/tauri.conf.json
  3. 8 0
      frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg
  4. 41 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx
  5. 47 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseHeader.tsx
  6. 21 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/CellText.tsx
  7. 46 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/VirtualizedList.tsx
  8. 9 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/constants.ts
  9. 17 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/dnd.context.ts
  10. 107 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drag.hooks.ts
  11. 88 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drop.hooks.ts
  12. 7 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/index.ts
  13. 180 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts
  14. 6 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts
  15. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/database/database.context.ts
  16. 19 5
      frontend/appflowy_tauri/src/appflowy_app/components/database/database_bd_svc.ts
  17. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridCell.tsx
  18. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridCheckboxCell.tsx
  19. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridSelectCell/GridSelectCell.tsx
  20. 6 5
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridTextCell.tsx
  21. 114 29
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridField.tsx
  22. 3 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCalculateRow.tsx
  23. 130 26
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow.tsx
  24. 36 0
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRowActions.tsx
  25. 21 37
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx
  26. 12 9
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridNewRow.tsx
  27. 15 17
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridRow.tsx
  28. 6 1
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/constants.ts
  29. 41 33
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx
  30. 0 78
      frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/VirtualizedRows.tsx
  31. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts
  32. 64 2
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
  33. 2 35
      frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx
  34. 6 1
      frontend/resources/translations/en.json

+ 1 - 1
frontend/appflowy_tauri/.eslintrc.cjs

@@ -23,6 +23,7 @@ module.exports = {
     '@typescript-eslint/await-thenable': 'error',
     '@typescript-eslint/no-namespace': 'error',
     '@typescript-eslint/no-unnecessary-type-assertion': 'error',
+    '@typescript-eslint/no-redeclare': 'error',
     '@typescript-eslint/prefer-for-of': 'warn',
     '@typescript-eslint/triple-slash-reference': 'error',
     '@typescript-eslint/unified-signatures': 'warn',
@@ -42,7 +43,6 @@ module.exports = {
     'no-invalid-this': 'error',
     'no-new-wrappers': 'error',
     'no-param-reassign': 'error',
-    'no-redeclare': 'error',
     'no-sequences': 'error',
     'no-throw-literal': 'error',
     'no-unsafe-finally': 'error',

+ 1 - 0
frontend/appflowy_tauri/src-tauri/tauri.conf.json

@@ -73,6 +73,7 @@
     },
     "windows": [
       {
+        "fileDropEnabled": false,
         "fullscreen": false,
         "height": 1200,
         "resizable": true,

+ 8 - 0
frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg

@@ -0,0 +1,8 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="9" y="3" width="2" height="2" rx="0.5" fill="#333333"/>
+<rect x="5" y="3" width="2" height="2" rx="0.5" fill="#333333"/>
+<rect x="9" y="7" width="2" height="2" rx="0.5" fill="#333333"/>
+<rect x="5" y="7" width="2" height="2" rx="0.5" fill="#333333"/>
+<rect x="9" y="11" width="2" height="2" rx="0.5" fill="#333333"/>
+<rect x="5" y="11" width="2" height="2" rx="0.5" fill="#333333"/>
+</svg>

+ 41 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx

@@ -0,0 +1,41 @@
+import { useEffect, useRef, useState } from 'react';
+import { proxy } from 'valtio';
+import { subscribeKey } from 'valtio/utils';
+import { DatabaseLayoutPB } from '@/services/backend';
+import { DndContext, DndContextDescriptor } from './_shared';
+import { VerticalScrollElementRefContext, DatabaseContext } from './database.context';
+import { useViewId, useConnectDatabase } from './database.hooks';
+import { DatabaseHeader } from './DatabaseHeader';
+import { Grid } from './grid';
+
+export const Database = () => {
+  const viewId = useViewId();
+  const verticalScrollElementRef = useRef<HTMLDivElement>(null);
+  const database = useConnectDatabase(viewId);
+  const [ layoutType, setLayoutType ] = useState(database.layoutType);
+  const dndContext = useRef(proxy<DndContextDescriptor>({
+    dragging: null,
+  }));
+
+  useEffect(() => {
+    return subscribeKey(database, 'layoutType', (value) => {
+      setLayoutType(value);
+    });
+  }, [database]);
+
+  return (
+    <div
+      ref={verticalScrollElementRef}
+      className="h-full overflow-y-auto"
+    >
+      <DatabaseHeader />
+      <VerticalScrollElementRefContext.Provider value={verticalScrollElementRef}>
+        <DndContext.Provider value={dndContext.current}>
+          <DatabaseContext.Provider value={database}>
+            {layoutType === DatabaseLayoutPB.Grid ? <Grid /> : null}
+          </DatabaseContext.Provider>
+        </DndContext.Provider >
+      </VerticalScrollElementRefContext.Provider>
+    </div>
+  );
+};

+ 47 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseHeader.tsx

@@ -0,0 +1,47 @@
+import { FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
+import { t } from 'i18next';
+import { PageController } from '$app/stores/effects/workspace/page/page_controller';
+import { useViewId } from './database.hooks';
+
+export const DatabaseHeader = () => {
+  const viewId = useViewId();
+  const [ title, setTitle ] = useState('');
+
+  const controller = useMemo(() => new PageController(viewId), [ viewId ]);
+
+  useEffect(() => {
+    void controller.getPage().then(page => {
+      setTitle(page.name);
+    });
+
+    void controller.subscribe({
+      onPageChanged: (page) => {
+        setTitle(page.name);
+      },
+    });
+
+    return () => {
+      void controller.unsubscribe();
+    };
+  }, [ controller ]);
+
+  const handleInput = useCallback<FormEventHandler>((event) => {
+    const newTitle = (event.target as HTMLInputElement).value;
+
+    void controller.updatePage({
+      id: viewId,
+      name: newTitle,
+    });
+  }, [ viewId, controller ]);
+
+  return (
+    <div className="px-16 pt-8 mb-6">
+      <input
+        className="text-3xl font-semibold"
+        value={title}
+        placeholder={t('grid.title.placeholder')}
+        onInput={handleInput}
+      />
+    </div>
+  );
+};

+ 21 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/CellText.tsx

@@ -0,0 +1,21 @@
+import React, { HTMLAttributes, PropsWithChildren } from 'react';
+
+export interface CellTextProps {
+  className?: string;
+}
+
+export const CellText = React.forwardRef<HTMLDivElement, PropsWithChildren<HTMLAttributes<HTMLDivElement>>>(function CellText(props, ref) {
+  const { children, className, ...other } = props;
+
+  return (
+    <div
+      ref={ref}
+      className={['flex p-2', className].join(' ')}
+      {...other}
+    >
+      <span className="flex-1 text-sm truncate">
+        {children}
+      </span>
+    </div>
+  );
+});

+ 46 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/VirtualizedList.tsx

@@ -0,0 +1,46 @@
+import { Virtualizer } from '@tanstack/react-virtual';
+import React, { CSSProperties, FC } from 'react';
+
+export interface VirtualizedListProps {
+  className?: string;
+  style?: CSSProperties | undefined;
+  virtualizer: Virtualizer<Element, Element>,
+  itemClassName?: string;
+  renderItem: (index: number) => React.ReactNode;
+}
+
+export const VirtualizedList: FC<VirtualizedListProps> = ({
+  className,
+  style,
+  itemClassName,
+  virtualizer,
+  renderItem,
+}) => {
+  const virtualItems = virtualizer.getVirtualItems();
+  const { horizontal } = virtualizer.options;
+  const sizeProp = horizontal ? 'width' : 'height';
+  const before = virtualItems.at(0)?.start ?? 0;
+  const after = virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
+
+  return (
+    <div className={className} style={style}>
+      {before > 0 && <div style={{ [sizeProp]: before }} />}
+      {virtualItems.map((virtualItem) => {
+        const { key, index, size } = virtualItem;
+
+        return (
+          <div
+            key={key}
+            className={itemClassName}
+            style={{ [sizeProp]: size }}
+            data-key={key}
+            data-index={index}
+          >
+            {renderItem(index)}
+          </div>
+        );
+      })}
+      {after > 0 && <div style={{ [sizeProp]: after }} />}
+    </div>
+  );
+};

+ 9 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/constants.ts

@@ -0,0 +1,9 @@
+export enum DragType {
+  Row = 'row',
+  Field = 'field',
+}
+
+export enum DropPosition {
+  Before = 0,
+  After = 1,
+}

+ 17 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/dnd.context.ts

@@ -0,0 +1,17 @@
+import { createContext } from 'react';
+import { proxy } from 'valtio';
+
+export interface DragItem<T = Record<string, unknown>> {
+  type: string;
+  data: T;
+}
+
+export interface DndContextDescriptor {
+  dragging: DragItem | null,
+}
+
+const defaultDndContext: DndContextDescriptor = proxy({
+  dragging: null,
+});
+
+export const DndContext = createContext<DndContextDescriptor>(defaultDndContext);

+ 107 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drag.hooks.ts

@@ -0,0 +1,107 @@
+import {
+  DragEventHandler,
+  useCallback,
+  useContext,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { DndContext } from './dnd.context';
+import { autoScrollOnEdge, EdgeGap, getScrollParent, ScrollDirection } from './utils';
+
+export interface UseDraggableOptions {
+  type: string;
+  effectAllowed?: DataTransfer['effectAllowed'];
+  data?: Record<string, any>;
+  disabled?: boolean;
+  scrollOnEdge?: {
+    direction?: ScrollDirection;
+    edgeGap?: number | Partial<EdgeGap>;
+  };
+}
+
+export const useDraggable = ({
+  type,
+  effectAllowed = 'copyMove',
+  data,
+  disabled,
+  scrollOnEdge,
+}: UseDraggableOptions) => {
+  const scrollDirection = scrollOnEdge?.direction;
+  const edgeGap = scrollOnEdge?.edgeGap;
+
+  const context = useContext(DndContext);
+  const typeRef = useRef(type);
+  const dataRef = useRef(data);
+  const previewRef = useRef<Element | null>(null);
+  const [ isDragging, setIsDragging ] = useState(false);
+
+  typeRef.current = type;
+  dataRef.current = data;
+
+  const setPreviewRef = useCallback((previewElement: null | Element) => {
+    previewRef.current = previewElement;
+  }, []);
+
+  const attributes = useMemo(() => {
+    if (disabled) {
+      return {};
+    }
+
+    return {
+      draggable: true,
+    };
+  }, [disabled]);
+
+  const onDragStart = useCallback<DragEventHandler>((event) => {
+    setIsDragging(true);
+    context.dragging = {
+      type: typeRef.current,
+      data: dataRef.current ?? {},
+    };
+
+    const { dataTransfer } = event;
+    const previewNode = previewRef.current;
+
+    dataTransfer.effectAllowed = effectAllowed;
+
+    if (previewNode) {
+      const { clientX, clientY } = event;
+      const rect = previewNode.getBoundingClientRect();
+
+      dataTransfer.setDragImage(previewNode, clientX - rect.x, clientY - rect.y);
+    }
+
+    if (scrollDirection === undefined) {
+      return;
+    }
+
+    const scrollParent: HTMLElement | null = getScrollParent(event.target as HTMLElement, scrollDirection);
+
+    if (scrollParent) {
+      autoScrollOnEdge({
+        element: scrollParent,
+        direction: scrollDirection,
+        edgeGap,
+      });
+    }
+  }, [ context, effectAllowed, scrollDirection, edgeGap ]);
+
+  const onDragEnd = useCallback<DragEventHandler>(() => {
+    setIsDragging(false);
+    context.dragging = null;
+  }, [ context ]);
+
+  const listeners = useMemo(() => ({
+    onDragStart,
+    onDragEnd,
+  }), [ onDragStart, onDragEnd]);
+
+  return {
+    isDragging,
+    previewRef,
+    attributes,
+    listeners,
+    setPreviewRef,
+  };
+};

+ 88 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drop.hooks.ts

@@ -0,0 +1,88 @@
+import { DragEventHandler, useContext, useState, useMemo, useCallback } from 'react';
+import { useSnapshot } from 'valtio';
+import { DragItem, DndContext } from './dnd.context';
+
+interface UseDroppableOptions {
+  accept: string;
+  dropEffect?: DataTransfer['dropEffect'];
+  disabled?: boolean;
+  onDragOver?: DragEventHandler,
+  onDrop?: (data: DragItem) => void;
+}
+
+export const useDroppable = ({
+  accept,
+  dropEffect = 'move',
+  disabled,
+  onDragOver: handleDragOver,
+  onDrop: handleDrop,
+}: UseDroppableOptions) => {
+  const dndContext = useContext(DndContext);
+  const dndSnapshot = useSnapshot(dndContext);
+
+  const [ dragOver, setDragOver ] = useState(false);
+  const canDrop = useMemo(
+    () => !disabled && dndSnapshot.dragging?.type === accept,
+    [ disabled, accept, dndSnapshot.dragging?.type ],
+  );
+  const isOver = useMemo(()=> canDrop && dragOver, [ canDrop, dragOver ]);
+
+  const onDragEnter = useCallback<DragEventHandler>((event) => {
+    if (!canDrop) {
+      return;
+    }
+
+    event.preventDefault();
+    event.dataTransfer.dropEffect = dropEffect;
+
+    setDragOver(true);
+  }, [ canDrop, dropEffect ]);
+
+  const onDragOver = useCallback<DragEventHandler>((event) => {
+    if (!canDrop) {
+      return;
+    }
+
+    event.preventDefault();
+    event.dataTransfer.dropEffect = dropEffect;
+
+    setDragOver(true);
+    handleDragOver?.(event);
+  }, [ canDrop, dropEffect, handleDragOver ]);
+
+  const onDragLeave = useCallback(() => {
+    if (!canDrop) {
+      return;
+    }
+
+    setDragOver(false);
+  }, [ canDrop ]);
+
+  const onDrop = useCallback(() => {
+    if (!canDrop) {
+      return;
+    }
+
+    const dragging = dndSnapshot.dragging;
+
+    if (!dragging) {
+      return;
+    }
+
+    setDragOver(false);
+    handleDrop?.(dragging);
+  }, [ canDrop, dndSnapshot.dragging, handleDrop ]);
+
+  const listeners = useMemo(() => ({
+    onDragEnter,
+    onDragOver,
+    onDragLeave,
+    onDrop,
+  }), [ onDragEnter, onDragOver, onDragLeave, onDrop ]);
+
+  return {
+    isOver,
+    canDrop,
+    listeners,
+  };
+};

+ 7 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/index.ts

@@ -0,0 +1,7 @@
+export * from './dnd.context';
+export * from './drag.hooks';
+export * from './drop.hooks';
+export {
+  ScrollDirection,
+  Edge,
+} from './utils';

+ 180 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts

@@ -0,0 +1,180 @@
+import { interval } from '$app/utils/tool';
+
+export enum Edge {
+  Top = 'top',
+  Bottom = 'bottom',
+  Left = 'left',
+  Right = 'right',
+}
+
+export enum ScrollDirection {
+  Horizontal = 'horizontal',
+  Vertical = 'vertical',
+}
+
+export interface EdgeGap {
+  top: number;
+  bottom: number;
+  left: number;
+  right: number;
+}
+
+export const isReachEdge = (element: Element, edge: Edge) => {
+  switch (edge) {
+    case Edge.Left:
+      return element.scrollLeft === 0;
+    case Edge.Right:
+      return element.scrollLeft + element.clientWidth === element.scrollWidth;
+    case Edge.Top:
+      return element.scrollTop === 0;
+    case Edge.Bottom:
+      return element.scrollTop + element.clientHeight === element.scrollHeight;
+    default:
+      return true;
+  }
+};
+
+export const scrollBy = (element: Element, edge: Edge, offset: number) => {
+  let step = offset;
+  let prop = edge;
+
+  if (edge === Edge.Left || edge === Edge.Top) {
+    step = -offset;
+  } else if (edge === Edge.Right) {
+    prop = Edge.Left;
+  } else if (edge === Edge.Bottom) {
+    prop = Edge.Top;
+  }
+
+  element.scrollBy({ [prop]: step });
+};
+
+export const scrollElement = (element: Element, edge: Edge, offset: number) => {
+  if (isReachEdge(element, edge)) {
+    return;
+  }
+
+  scrollBy(element, edge, offset);
+};
+
+export const calculateLeaveEdge = (
+  { x: mouseX, y: mouseY }: { x: number; y: number },
+  rect: DOMRect,
+  gaps: EdgeGap,
+  direction: ScrollDirection,
+) => {
+  if (direction === ScrollDirection.Horizontal) {
+    if (mouseX - rect.left < gaps.left) {
+      return Edge.Left;
+    }
+
+    if (rect.right - mouseX < gaps.right) {
+      return Edge.Right;
+    }
+  }
+
+  if (direction === ScrollDirection.Vertical) {
+    if (mouseY - rect.top < gaps.top) {
+      return Edge.Top;
+    }
+
+    if (rect.bottom - mouseY < gaps.bottom) {
+      return Edge.Bottom;
+    }
+  }
+
+  return null;
+};
+
+export const getScrollParent = (element: HTMLElement | null, direction: ScrollDirection): HTMLElement | null => {
+  if (element === null) {
+    return null;
+  }
+
+  if (direction === ScrollDirection.Horizontal && element.scrollWidth > element.clientWidth) {
+    return element;
+  }
+
+  if (direction === ScrollDirection.Vertical && element.scrollHeight > element.clientHeight) {
+    return element;
+  }
+
+  return getScrollParent(element.parentElement, direction);
+};
+
+export interface AutoScrollOnEdgeOptions {
+  element: HTMLElement;
+  direction: ScrollDirection;
+  edgeGap?: number | Partial<EdgeGap>;
+  step?: number;
+}
+
+const defaultEdgeGap = 30;
+
+export const autoScrollOnEdge = ({
+  element,
+  direction,
+  edgeGap,
+  step = 8,
+}: AutoScrollOnEdgeOptions) => {
+  const gaps = typeof edgeGap === 'number'
+    ? {
+      top: edgeGap,
+      bottom: edgeGap,
+      left: edgeGap,
+      right: edgeGap,
+    }
+    : {
+      top: defaultEdgeGap,
+      bottom: defaultEdgeGap,
+      left: defaultEdgeGap,
+      right: defaultEdgeGap,
+      ...edgeGap,
+    };
+
+  const keepScroll = interval(scrollElement, 8);
+
+  let leaveEdge: Edge | null = null;
+
+  const onDragOver = (event: DragEvent) => {
+    const rect = element.getBoundingClientRect();
+
+    leaveEdge = calculateLeaveEdge(
+      { x: event.clientX, y: event.clientY },
+      rect,
+      gaps,
+      direction,
+    );
+
+    if (leaveEdge) {
+      keepScroll(element, leaveEdge, step);
+    } else {
+      keepScroll.cancel();
+    }
+  };
+
+  const onDragLeave = () => {
+    if (!leaveEdge) {
+      return;
+    }
+
+    keepScroll(element, leaveEdge, step * 2);
+  };
+
+  const cleanup = () => {
+    console.log('document drag end');
+    keepScroll.cancel();
+
+    element.removeEventListener('dragover', onDragOver);
+    element.removeEventListener('dragleave', onDragLeave);
+
+    document.removeEventListener('dragend', cleanup);
+  };
+
+  element.addEventListener('dragover', onDragOver);
+  element.addEventListener('dragleave', onDragLeave);
+
+  document.addEventListener('dragend', cleanup);
+
+  return cleanup;
+};

+ 6 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts

@@ -0,0 +1,6 @@
+export * from './constants';
+
+export * from './dnd';
+
+export * from './VirtualizedList';
+export * from './CellText';

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/database/database.context.ts

@@ -1,7 +1,7 @@
-import { Database } from '$app/interfaces/database';
-import { DatabaseLayoutPB } from '@/services/backend';
 import { RefObject, createContext, createRef } from 'react';
 import { proxy } from 'valtio';
+import { Database } from '$app/interfaces/database';
+import { DatabaseLayoutPB } from '@/services/backend';
 
 export const VerticalScrollElementRefContext = createContext<RefObject<Element>>(createRef());
 export const DatabaseContext = createContext<Database>(proxy({

+ 19 - 5
frontend/appflowy_tauri/src/appflowy_app/components/database/database_bd_svc.ts

@@ -48,6 +48,7 @@ import {
   DatabaseSnapshotPB,
   FilterPB,
   SortPB,
+  MoveRowPayloadPB,
 } from '@/services/backend';
 import {
   DatabaseEventGetDatabases,
@@ -92,6 +93,7 @@ import {
   DatabaseEventGetDatabaseSnapshots,
   DatabaseEventGetAllFilters,
   DatabaseEventGetAllSorts,
+  DatabaseEventMoveRow,
 } from "@/services/backend/events/flowy-database2";
 
 export async function getDatabases(): Promise<DatabaseDescriptionPB[]> {
@@ -164,7 +166,7 @@ export async function setLayoutSetting(viewId: string, setting: {
       show_week_numbers: setting.calendar.showWeekNumbers,
     } : undefined,
   });
-  
+
   const result = await DatabaseEventSetLayoutSetting(payload);
 
   return result.unwrap();
@@ -493,13 +495,25 @@ export async function deleteRow(viewId: string, rowId: string, groupId?: string)
   return result.unwrap();
 }
 
+export async function moveRow(viewId: string, fromRowId: string, toRowId: string): Promise<void> {
+  const payload = MoveRowPayloadPB.fromObject({
+    view_id: viewId,
+    from_row_id: fromRowId,
+    to_row_id: toRowId,
+  });
+
+  const result = await DatabaseEventMoveRow(payload);
+
+  return result.unwrap();
+}
+
 /**
  * Move the row from one group to another group
  *
- * @param fromRowId 
- * @param toGroupId 
+ * @param fromRowId
+ * @param toGroupId
  * @param toRowId used to locate the moving row location.
- * @returns 
+ * @returns
  */
 export async function moveGroupRow(viewId: string, fromRowId: string, toGroupId: string, toRowId?: string): Promise<void> {
   const payload = MoveGroupRowPayloadPB.fromObject({
@@ -671,7 +685,7 @@ export async function updateDateCell(
   });
 
   const result = await DatabaseEventUpdateDateCell(payload);
-  
+
   return result.unwrap();
 }
 

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridCell.tsx

@@ -34,5 +34,5 @@ export const GridCell: FC<GridCellProps> = ({
   }, [field.type]);
 
   // TODO: find a better way to check cell type.
-  return <RenderCell rowId={rowId} field={field} cell={cell as any} />
-};
+  return <RenderCell rowId={rowId} field={field} cell={cell as any} />;
+};

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridCheckboxCell.tsx

@@ -17,7 +17,7 @@ export const GridCheckboxCell: FC<{
   }, [viewId, rowId, field.id ]);
 
   return (
-    <div className="flex h-full items-center px-3">
+    <div className="flex items-center px-2">
       <Checkbox
         disableRipple
         style={{ padding: 0 }}

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridSelectCell/GridSelectCell.tsx

@@ -109,7 +109,7 @@ export const GridSelectCell: FC<{
     <Select
       className="w-full"
       classes={{
-        select: 'flex items-center gap-2 px-4 py-2 h-6',
+        select: 'flex items-center gap-2 px-4 py-1 h-6',
       }}
       size="small"
       value={selectedIds}
@@ -140,4 +140,4 @@ export const GridSelectCell: FC<{
         ))}
     </Select>
   );
-};
+};

+ 6 - 5
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCell/GridTextCell.tsx

@@ -3,6 +3,7 @@ import { FC, FormEventHandler, useCallback, useEffect, useRef, useState } from '
 import { Database } from '$app/interfaces/database';
 import * as service from '$app/components/database/database_bd_svc';
 import { useViewId } from '../../database.hooks';
+import { CellText } from '../../_shared';
 
 export const GridTextCell: FC<{
   rowId: string;
@@ -42,19 +43,19 @@ export const GridTextCell: FC<{
 
   return (
     <>
-      <div
+      <CellText
         ref={cellRef}
-        className="relative flex h-full items-center p-3 text-xs font-medium"
+        className="w-full"
         onDoubleClick={handleDoubleClick}
       >
         {cell?.data}
-      </div>
+      </CellText>
       {editing && (
         <Popover
           open={editing}
           anchorEl={cellRef.current}
           PaperProps={{
-            className: 'flex',
+            className: 'flex p-2 border border-blue-400',
             style: { width, borderRadius: 0, boxShadow: 'none' },
           }}
           transformOrigin={{
@@ -65,7 +66,7 @@ export const GridTextCell: FC<{
           onClose={handleClose}
         >
           <TextareaAutosize
-            className="resize-none p-3 text-xs font-medium border border-blue-400"
+            className="resize-none text-sm"
             autoFocus
             autoCorrect="off"
             value={text}

+ 114 - 29
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridField.tsx

@@ -1,7 +1,10 @@
-import { IconButton } from '@mui/material';
-import { FC, useCallback, useRef, useState } from 'react';
-import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
+import { Button, Tooltip } from '@mui/material';
+import { DragEventHandler, FC, useCallback, useMemo, useState } from 'react';
 import { Database } from '$app/interfaces/database';
+import { throttle } from '$app/utils/tool';
+import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
+import * as service from '../../database_bd_svc';
+import { useDatabase, useViewId } from '../../database.hooks';
 import { FieldTypeSvg } from './FieldTypeSvg';
 import { GridFieldMenu } from './GridFieldMenu';
 
@@ -10,37 +13,119 @@ export interface GridFieldProps {
 }
 
 export const GridField: FC<GridFieldProps> = ({ field }) => {
-  const anchorEl = useRef<HTMLDivElement>(null);
-  const [open, setOpen] = useState(false);
+  const viewId = useViewId();
+  const { fields } = useDatabase();
+  const [ openMenu, setOpenMenu ] = useState(false);
+  const [ openTooltip, setOpenTooltip ] = useState(false);
+  const [ dropPosition, setDropPosition ] = useState<DropPosition>(DropPosition.Before);
 
   const handleClick = useCallback(() => {
-    setOpen(true);
+    setOpenMenu(true);
   }, []);
 
-  const handleClose = useCallback(() => {
-    setOpen(false);
+  const handleMenuClose = useCallback(() => {
+    setOpenMenu(false);
   }, []);
 
+  const handleTooltipOpen = useCallback(() => {
+    setOpenTooltip(true);
+  }, []);
+
+  const handleTooltipClose = useCallback(() => {
+    setOpenTooltip(false);
+  }, []);
+
+  const draggingData = useMemo(() => ({
+    field,
+  }), [field]);
+
+  const {
+    isDragging,
+    attributes,
+    listeners,
+    setPreviewRef,
+    previewRef,
+  } = useDraggable({
+    type: DragType.Field,
+    data: draggingData,
+    scrollOnEdge: {
+      direction: ScrollDirection.Horizontal,
+    },
+  });
+
+  const onDragOver = useMemo<DragEventHandler>(() => {
+    return throttle((event) => {
+      const element = previewRef.current;
+
+      if (!element) {
+        return;
+      }
+
+      const { left, right } = element.getBoundingClientRect();
+      const middle = (left + right) / 2;
+
+      setDropPosition(event.clientX < middle ? DropPosition.Before : DropPosition.After);
+    }, 20);
+  }, [previewRef]);
+
+  const onDrop = useCallback(({ data }: DragItem) => {
+    const dragField = data.field as Database.Field;
+    const fromIndex = fields.findIndex(item => item.id === dragField.id);
+    const dropIndex = fields.findIndex(item => item.id === field.id);
+    const toIndex = dropIndex + dropPosition + (fromIndex < dropIndex ? -1 : 0);
+
+    if (fromIndex === toIndex) {
+      return;
+    }
+
+    void service.moveField(viewId, dragField.id, fromIndex, toIndex);
+  }, [viewId, field, fields, dropPosition]);
+
+  const {
+    isOver,
+    listeners: dropListeners,
+  } = useDroppable({
+    accept: DragType.Field,
+    disabled: isDragging,
+    onDragOver,
+    onDrop,
+  });
+
   return (
-    <div
-      ref={anchorEl}
-      className="flex items-center p-3 h-full"
-    >
-      <div className="flex flex-1 items-center">
-        <FieldTypeSvg type={field.type} className="text-base mr-2" />
-        <span className="text-xs font-medium">
-          {field.name}
-        </span>
-      </div>
-      <IconButton size="small" onClick={handleClick}>
-        <DetailsSvg />
-      </IconButton>
-      <GridFieldMenu
-        field={field}
-        open={open}
-        anchorEl={anchorEl.current}
-        onClose={handleClose}
-      />
-    </div>
+    <>
+      <Tooltip
+        open={openTooltip && !isDragging}
+        title={field.name}
+        placement="right"
+        enterDelay={1000}
+        enterNextDelay={1000}
+        onOpen={handleTooltipOpen}
+        onClose={handleTooltipClose}
+      >
+        <Button
+          ref={setPreviewRef}
+          className="flex items-center px-2 w-full relative"
+          disableRipple
+          onClick={handleClick}
+          {...attributes}
+          {...listeners}
+          {...dropListeners}
+        >
+          <FieldTypeSvg className="text-base mr-1" type={field.type} />
+          <span className="flex-1 text-left text-xs truncate">
+            {field.name}
+          </span>
+          {isOver && <div className={`absolute top-0 bottom-0 w-0.5 bg-blue-500 z-10 ${dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'}`} />}
+        </Button>
+      </Tooltip>
+      {openMenu && (
+        <GridFieldMenu
+          field={field}
+          open={openMenu}
+          anchorEl={previewRef.current}
+          onClose={handleMenuClose}
+        />
+      )}
+    </>
   );
-};
+};

+ 3 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCalculateRow.tsx

@@ -0,0 +1,3 @@
+export const GridCalculateRow = () => {
+  return null;
+};

+ 130 - 26
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow.tsx

@@ -1,36 +1,140 @@
+import { Virtualizer } from '@tanstack/react-virtual';
+import { IconButton, Tooltip } from '@mui/material';
+import { t } from 'i18next';
+import { DragEventHandler, FC, useCallback, useMemo, useState } from 'react';
 import { Database } from '$app/interfaces/database';
-import { VirtualItem } from '@tanstack/react-virtual';
-import { FC } from 'react';
-import { useDatabase } from '../../database.hooks';
+import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
+import { throttle } from '$app/utils/tool';
+import { useDatabase, useViewId } from '../../database.hooks';
+import * as service from '../../database_bd_svc';
+import { DragItem, DragType, DropPosition, VirtualizedList, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
 import { GridCell } from '../GridCell';
+import { GridCellRowActions } from './GridCellRowActions';
 
-export const GridCellRow: FC<{
-  columnVirtualItems: VirtualItem[];
+export interface GridCellRowProps {
   row: Database.Row;
-  before: number;
-  after: number;
-}> = ({ columnVirtualItems, row, before, after }) => {
+  virtualizer: Virtualizer<Element, Element>;
+}
+
+export const GridCellRow: FC<GridCellRowProps> = ({
+  row,
+  virtualizer,
+}) => {
+  const viewId = useViewId();
   const { fields } = useDatabase();
 
+  const [ hover, setHover ] = useState(false);
+  const [ openTooltip, setOpenTooltip ] = useState(false);
+  const [ dropPosition, setDropPosition ] = useState<DropPosition>(DropPosition.Before);
+
+  const handleMouseEnter = useCallback(() => {
+    setHover(true);
+  }, []);
+
+  const handleMouseLeave = useCallback(() => {
+    setHover(false);
+  }, []);
+
+  const handleTooltipOpen = useCallback(() => {
+    setOpenTooltip(true);
+  }, []);
+
+  const handleTooltipClose = useCallback(() => {
+    setOpenTooltip(false);
+  }, []);
+
+  const dragData = useMemo(() => ({
+    row,
+  }), [row]);
+
+  const {
+    isDragging,
+    attributes,
+    listeners,
+    setPreviewRef,
+    previewRef,
+  } = useDraggable({
+    type: DragType.Row,
+    data: dragData,
+    scrollOnEdge: {
+      direction: ScrollDirection.Vertical,
+    },
+  });
+
+  const onDragOver = useMemo<DragEventHandler>(() => {
+    return throttle((event) => {
+      const element = previewRef.current;
+
+      if (!element) {
+        return;
+      }
+
+      const { top, bottom } = element.getBoundingClientRect();
+      const middle = (top + bottom) / 2;
+
+      setDropPosition(event.clientY < middle ? DropPosition.Before : DropPosition.After);
+    }, 20);
+  }, [previewRef]);
+
+  const onDrop = useCallback(({ data }: DragItem) => {
+    void service.moveRow(viewId, (data.row as Database.Row).id, row.id);
+  }, [viewId, row.id]);
+
+  const {
+    isOver,
+    listeners: dropListeners,
+  } = useDroppable({
+    accept: DragType.Row,
+    disabled: isDragging,
+    onDragOver,
+    onDrop,
+  });
+
   return (
-    <>
-      <div className="flex">
-        {before > 0 && <div style={{ width: before }} />}
-        {columnVirtualItems.map(virtualColumn => (
-          <div
-            key={virtualColumn.key}
-            className="border-r border-line-divider overflow-hidden"
-            data-index={virtualColumn.index}
-            style={{
-              width: virtualColumn.size,
-            }}
+    <div
+      className="flex grow ml-[-49px]"
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+      {...dropListeners}
+    >
+      <GridCellRowActions
+        className={hover ? 'visible' : 'invisible'}
+        rowId={row.id}
+      >
+        <Tooltip
+          placement="top"
+          title={t('grid.row.drag')}
+          open={openTooltip && !isDragging}
+          onOpen={handleTooltipOpen}
+          onClose={handleTooltipClose}
+        >
+          <IconButton
+            className="mx-1 cursor-grab active:cursor-grabbing"
+            {...attributes}
+            {...listeners}
           >
-            <GridCell rowId={row.id} field={fields[virtualColumn.index]} />
-          </div>
-        ))}
-        {after > 0 && <div style={{ width: after }} />}
+            <DragSvg className='-mx-1' />
+          </IconButton>
+        </Tooltip>
+      </GridCellRowActions>
+      <div
+        ref={setPreviewRef}
+        className={`flex grow border-b border-line-divider relative ${isDragging ? 'bg-blue-50' : ''}`}
+      >
+        <VirtualizedList
+          className="flex"
+          itemClassName="flex border-r border-line-divider"
+          virtualizer={virtualizer}
+          renderItem={index => (
+            <GridCell
+              rowId={row.id}
+              field={fields[index]}
+            />
+          )}
+        />
+        <div className="min-w-20 grow" />
+        {isOver && <div className={`absolute left-0 right-0 h-0.5 bg-blue-500 z-10 ${dropPosition === DropPosition.Before ? 'top-[-1px]' : 'top-full'}`} />}
       </div>
-      <div className="w-44 grow" />
-    </>
+    </div>
   );
-}
+};

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRowActions.tsx

@@ -0,0 +1,36 @@
+import { IconButton, Tooltip } from '@mui/material';
+import { FC, PropsWithChildren, useCallback } from 'react';
+import { ReactComponent as AddSvg } from '$app/assets/add.svg';
+import * as service from '$app/components/database/database_bd_svc';
+import { useViewId } from '../../database.hooks';
+import { t } from 'i18next';
+
+export interface GridCellRowActionsProps {
+  className?: string;
+  rowId: string;
+}
+
+export const GridCellRowActions: FC<PropsWithChildren<GridCellRowActionsProps>> = ({
+  className,
+  rowId,
+  children,
+}) => {
+  const viewId = useViewId();
+
+  const handleInsertRowClick = useCallback(() => {
+    void service.createRow(viewId, {
+      startRowId: rowId,
+    });
+  }, [viewId, rowId]);
+
+  return (
+    <div className={`inline-flex items-center ${className}`}>
+      <Tooltip placement="top" title={t('grid.row.add')}>
+        <IconButton onClick={handleInsertRowClick}>
+          <AddSvg />
+        </IconButton>
+      </Tooltip>
+      {children}
+    </div>
+  );
+};

+ 21 - 37
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx

@@ -1,57 +1,41 @@
-import { VirtualItem } from '@tanstack/react-virtual';
-import { t } from 'i18next';
+import { Virtualizer } from '@tanstack/react-virtual';
 import { FC } from 'react';
 import { Button } from '@mui/material';
 import { FieldType } from '@/services/backend';
 import { ReactComponent as AddSvg } from '$app/assets/add.svg';
 import * as service from '$app/components/database/database_bd_svc';
 import { useDatabase } from '../../database.hooks';
+import { VirtualizedList } from '../../_shared';
 import { GridField } from '../GridField';
 
-export const GridFieldRow: FC<{
-  columnVirtualItems: VirtualItem[];
-  before: number;
-  after: number;
-}> = ({ columnVirtualItems, before, after }) => {
+export interface GridFieldRowProps {
+  virtualizer: Virtualizer<Element, Element>;
+}
+
+export const GridFieldRow: FC<GridFieldRowProps> = ({
+  virtualizer,
+}) => {
   const { viewId, fields } = useDatabase();
   const handleClick = async () => {
     await service.createFieldTypeOption(viewId, FieldType.RichText);
   };
 
   return (
-    <>
-      <div
-        className="flex border-t border-line-divider"
-        style={{
-          height: 41,
-        }}
-      >
-        {before > 0 && <div style={{ width: before }} />}
-        {columnVirtualItems.map(virtualColumn => (
-          <div
-            key={virtualColumn.key}
-            className="border-r border-line-divider"
-            data-index={virtualColumn.index}
-            style={{
-              width: `${virtualColumn.size}px`,
-            }}
-          >
-            <GridField field={fields[virtualColumn.index]} />
-          </div>
-        ))}
-        {after > 0 && <div style={{ width: after }} />}
-      </div>
-      <div className="w-44 grow flex items-center pl-2 border-t border-line-divider">
+    <div className="flex grow border-b border-line-divider">
+      <VirtualizedList
+        className="flex"
+        virtualizer={virtualizer}
+        itemClassName="flex border-r border-line-divider"
+        renderItem={index => <GridField field={fields[index]} />}
+      />
+      <div className="min-w-20 grow">
         <Button
-          variant="text"
-          color="inherit"
+          className="w-full h-full"
           size="small"
           startIcon={<AddSvg />}
           onClick={handleClick}
-        >
-          {t('grid.field.newColumn')}
-        </Button>
+        />
       </div>
-    </>
+    </div>
   );
-}
+};

+ 12 - 9
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridNewRow.tsx

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 import { t } from 'i18next';
+import { Button } from '@mui/material';
 import { ReactComponent as AddSvg } from '$app/assets/add.svg';
 import * as service from '$app/components/database/database_bd_svc';
 import { useDatabase, useViewId } from '../../database.hooks';
@@ -16,14 +17,16 @@ export const GridNewRow = () => {
   }, [viewId, lastRowId]);
 
   return (
-    <div
-      className="flex flex-1 h-9 items-center px-1 py-2 cursor-pointer"
-      onClick={handleClick}
-    >
-      <AddSvg className="text-base mr-1" />
-      <span className="text-xs font-medium">
-        {t('grid.row.newRow')}
-      </span>
+    <div className="flex grow border-b border-line-divider">
+      <Button
+        className="grow justify-start"
+        onClick={handleClick}
+      >
+        <span className="inline-flex items-center sticky left-[72px]">
+          <AddSvg className="text-base mr-1" />
+          {t('grid.row.newRow')}
+        </span>
+      </Button>
     </div>
   );
-};
+};

+ 15 - 17
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridRow.tsx

@@ -1,38 +1,36 @@
-import { VirtualItem } from '@tanstack/react-virtual';
+import { Virtualizer } from '@tanstack/react-virtual';
 import { FC } from 'react';
 import { RenderRow, RenderRowType } from './constants';
 import { GridCellRow } from './GridCellRow';
 import { GridFieldRow } from './GridFieldRow';
 import { GridNewRow } from './GridNewRow';
+import { GridCalculateRow } from './GridCalculateRow';
 
-export const GridRow: FC<{
+export interface GridRowProps {
   row: RenderRow;
-  columnVirtualItems: VirtualItem[];
-  before: number;
-  after: number;
-}> = ({ row, columnVirtualItems, before, after }) => {
+  virtualizer: Virtualizer<Element, Element>;
+}
+
+export const GridRow: FC<GridRowProps> = ({
+  row,
+  virtualizer,
+}) => {
 
   switch (row.type) {
     case RenderRowType.Row:
       return (
         <GridCellRow
           row={row.data}
-          columnVirtualItems={columnVirtualItems}
-          before={before}
-          after={after}
+          virtualizer={virtualizer}
         />
       );
     case RenderRowType.Fields:
-      return (
-        <GridFieldRow
-          columnVirtualItems={columnVirtualItems}
-          before={before}
-          after={after}
-        />
-      );
+      return <GridFieldRow virtualizer={virtualizer} />;
     case RenderRowType.NewRow:
       return <GridNewRow />;
+    case RenderRowType.Calculate:
+      return <GridCalculateRow />;
     default:
       return null;
   }
-};
+};

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/constants.ts

@@ -4,6 +4,7 @@ export enum RenderRowType {
   Fields = 'fields',
   Row = 'row',
   NewRow = 'new-row',
+  Calculate = 'calculate',
 }
 
 export interface FieldRenderRow {
@@ -19,4 +20,8 @@ export interface NewRenderRow {
   type: RenderRowType.NewRow;
 }
 
-export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow;
+export interface CalculateRenderRow {
+  type: RenderRowType.Calculate;
+}
+
+export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow;

+ 41 - 33
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx

@@ -1,26 +1,32 @@
-import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual';
+import { useVirtualizer } from '@tanstack/react-virtual';
 import { FC, useContext, useMemo, useRef } from 'react';
 import { VerticalScrollElementRefContext } from '../../database.context';
 import { useDatabase } from '../../database.hooks';
+import { VirtualizedList } from '../../_shared';
 import { GridRow, RenderRow, RenderRowType } from '../GridRow';
-import { VirtualizedRows } from './VirtualizedRows';
 
-const calculateBeforeAfter = (columnVirtualizer: Virtualizer<HTMLDivElement, Element>) => {
-  const columnVirtualItems = columnVirtualizer.getVirtualItems();
+const getRenderRowKey = (row: RenderRow) => {
+  if (row.type === RenderRowType.Row) {
+    return `row:${row.data.id}`;
+  }
 
-  return columnVirtualItems.length > 0
-    ? [
-      columnVirtualItems[0].start,
-      columnVirtualizer.getTotalSize() - columnVirtualItems[columnVirtualItems.length - 1].end,
-    ]
-    : [0, 0];
+  return row.type;
+};
+
+const getRenderRowHeight = (row: RenderRow) => {
+  const defaultRowHeight = 37;
+
+  if (row.type === RenderRowType.Row) {
+    return row.data.height ?? defaultRowHeight;
+  }
+
+  return defaultRowHeight;
 };
 
 export const GridTable: FC = () => {
   const verticalScrollElementRef = useContext(VerticalScrollElementRefContext);
-  const { rows, fields } = useDatabase();
-
   const horizontalScrollElementRef = useRef<HTMLDivElement>(null);
+  const { rows, fields } = useDatabase();
 
   const renderRows = useMemo<RenderRow[]>(() => {
     return [
@@ -34,12 +40,22 @@ export const GridTable: FC = () => {
       {
         type: RenderRowType.NewRow,
       },
+      {
+        type: RenderRowType.Calculate,
+      },
     ];
   }, [rows]);
 
-  const defaultColumnWidth = 221;
+  const rowVirtualizer = useVirtualizer({
+    count: renderRows.length,
+    overscan: 10,
+    getItemKey: i => getRenderRowKey(renderRows[i]),
+    getScrollElement: () => verticalScrollElementRef.current,
+    estimateSize: i => getRenderRowHeight(renderRows[i]),
+  });
 
-  const columnVirtualizer = useVirtualizer({
+  const defaultColumnWidth = 221;
+  const columnVirtualizer = useVirtualizer<Element, Element>({
     horizontal: true,
     count: fields.length,
     overscan: 5,
@@ -48,28 +64,20 @@ export const GridTable: FC = () => {
     estimateSize: (i) => fields[i].width ?? defaultColumnWidth,
   });
 
-  const columnVirtualItems = columnVirtualizer.getVirtualItems();
-  const [before, after] = calculateBeforeAfter(columnVirtualizer);
-
   return (
     <div
       ref={horizontalScrollElementRef}
-      className="overflow-y-hidden overflow-x-auto"
+      className="flex w-full overflow-x-auto"
+      style={{ minHeight: 'calc(100% - 132px)' }}
     >
-      <div className='px-16'>
-        <VirtualizedRows
-          scrollElementRef={verticalScrollElementRef}
-          rows={renderRows}
-          renderRow={(row) => (
-            <GridRow
-              row={row}
-              columnVirtualItems={columnVirtualItems}
-              before={before}
-              after={after}
-            />
-          )}
-        />
-      </div>
+      <VirtualizedList
+        className="flex flex-col basis-full px-16"
+        virtualizer={rowVirtualizer}
+        itemClassName="flex"
+        renderItem={index => (
+          <GridRow row={renderRows[index]} virtualizer={columnVirtualizer} />
+        )}
+      />
     </div>
   );
-};
+};

+ 0 - 78
frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/VirtualizedRows.tsx

@@ -1,78 +0,0 @@
-import { useVirtualizer } from '@tanstack/react-virtual';
-import { FC, RefObject } from 'react';
-import { RenderRow, RenderRowType } from '../GridRow';
-
-export interface VirtualizedRowsProps {
-  rows: RenderRow[];
-  scrollElementRef: RefObject<Element>;
-  defaultHeight?: number;
-  renderRow: (row: RenderRow, index: number) => React.ReactNode;
-}
-
-const getRenderRowKey = (row: RenderRow) => {
-  switch (row.type) {
-    case RenderRowType.Row:
-      return `row:${row.data.id}`;
-    case RenderRowType.Fields:
-      return 'fields';
-    case RenderRowType.NewRow:
-      return 'new-row';
-    default:
-      return '';
-  }
-};
-
-const getRenderRowHeight = (row: RenderRow) => {
-  switch (row.type) {
-    case RenderRowType.Row:
-      return row.data.height ?? 41;
-    case RenderRowType.Fields:
-      return 41;
-    case RenderRowType.NewRow:
-      return 36;
-    default:
-      return 0;
-  }
-};
-
-export const VirtualizedRows: FC<VirtualizedRowsProps> = ({
-  rows,
-  scrollElementRef,
-  renderRow,
-}) => {
-  const virtualizer = useVirtualizer({
-    count: rows.length,
-    overscan: 5,
-    getItemKey: i => getRenderRowKey(rows[i]),
-    getScrollElement: () => scrollElementRef.current,
-    estimateSize: i => getRenderRowHeight(rows[i]),
-  });
-
-  const virtualItems = virtualizer.getVirtualItems();
-
-  return (
-    <div
-      style={{
-        position: 'relative',
-        height: virtualizer.getTotalSize(),
-      }}
-    >
-      {virtualItems.map((virtualRow) => {
-        return (
-          <div
-            key={virtualRow.key}
-            className='absolute top-0 left-0 flex min-w-full border-b border-line-divider'
-            style={{
-              height: virtualRow.size,
-              transform: `translateY(${virtualRow.start}px)`,
-            }}
-            data-key={virtualRow.key}
-            data-index={virtualRow.index}
-          >
-            {renderRow(rows[virtualRow.index], virtualRow.index)}
-          </div>
-        );
-      })}
-    </div>
-  );
-};

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts

@@ -1,3 +1,3 @@
 export * from './database.context';
 export * from './database.hooks';
-export * from './grid';
+export * from './Database';

+ 64 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts

@@ -14,10 +14,14 @@ export function debounce(fn: (...args: any[]) => void, delay: number) {
   return debounceFn;
 }
 
-export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) {
+export function throttle<T extends (...args: any[]) => void = (...args: any[]) => void>(
+  fn: T,
+  delay: number,
+  immediate = true,
+): T {
   let timeout: NodeJS.Timeout | null = null;
 
-  return (...args: any[]) => {
+  const run = (...args: Parameters<T>) => {
     if (!timeout) {
       timeout = setTimeout(() => {
         timeout = null;
@@ -26,6 +30,8 @@ export function throttle(fn: (...args: any[]) => void, delay: number, immediate
       immediate && fn.apply(undefined, args);
     }
   };
+
+  return run as T;
 }
 
 export function get<T = any>(obj: any, path: string[], defaultValue?: any): T {
@@ -126,3 +132,59 @@ export function chunkArray<T>(array: T[], chunkSize: number) {
 
   return chunks;
 }
+
+/**
+ * Creates an interval that repeatedly calls the given function with a specified delay.
+ *
+ * @param {Function} fn - The function to be called repeatedly.
+ * @param {number} [delay] - The delay between function calls in milliseconds.
+ * @param {Object} [options] - Additional options for the interval.
+ * @param {boolean} [options.immediate] - Whether to immediately call the function when the interval is created. Default is true.
+ *
+ * @return {Function} - The function that runs the interval.
+ * @return {Function.cancel} - A method to cancel the interval.
+ *
+ * @example
+ * const log = interval((message) => console.log(message), 1000);
+ *
+ * log('foo'); // prints 'foo' every second.
+ *
+ * log('bar'); // change to prints 'bar' every second.
+ *
+ * log.cancel(); // stops the interval.
+ */
+export function interval<T extends (...args: any[]) => any = (...args: any[]) => any>(
+  fn: T,
+  delay?: number,
+  options?: { immediate?: boolean },
+): T & { cancel: () => void } {
+  const { immediate = true } = options || {};
+  let intervalId: NodeJS.Timer | null = null;
+  let parameters: any[] = [];
+
+  function run(...args: Parameters<T>) {
+    parameters = args;
+
+    if (intervalId !== null) {
+      return;
+    }
+
+    immediate && fn.apply(undefined, parameters);
+    intervalId = setInterval(() => {
+      fn.apply(undefined, parameters);
+    }, delay);
+  }
+
+  function cancel() {
+    if (intervalId === null) {
+      return;
+    }
+
+    clearInterval(intervalId);
+    intervalId = null;
+    parameters = [];
+  }
+
+  run.cancel = cancel;
+  return run as T & { cancel: () => void };
+}

+ 2 - 35
frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx

@@ -1,38 +1,5 @@
-import { useRef } from 'react';
-import { useSnapshot } from 'valtio';
-import { DatabaseLayoutPB } from '@/services/backend';
-import {
-  VerticalScrollElementRefContext,
-  DatabaseContext,
-  Grid,
-  useViewId,
-  useConnectDatabase,
-} from '../components/database';
+import { Database } from '../components/database';
 
 export const DatabasePage = () => {
-  const viewId = useViewId();
-  const scrollElementRef = useRef<HTMLDivElement>(null);
-  const database = useConnectDatabase(viewId);
-  const snapshot = useSnapshot(database);
-
-  return (
-    <div
-      ref={scrollElementRef}
-      className="scroll-container flex flex-col overflow-y-auto overflow-x-hidden h-full"
-    >
-      <div>
-        <div className="px-16 pt-8">
-          <h1 className="text-3xl font-semibold mb-6">Grid</h1>
-          <div className="text-lg font-semibold mb-9">
-            👋  Welcome to AppFlowy
-          </div>
-        </div>
-        <VerticalScrollElementRefContext.Provider value={scrollElementRef}>
-          <DatabaseContext.Provider value={database}>
-            {snapshot.layoutType === DatabaseLayoutPB.Grid ? <Grid /> : null}
-          </DatabaseContext.Provider>
-        </VerticalScrollElementRefContext.Provider>
-      </div>
-    </div>
-  );
+  return <Database />;
 };

+ 6 - 1
frontend/resources/translations/en.json

@@ -351,6 +351,9 @@
   "grid": {
     "deleteView": "Are you sure you want to delete this view?",
     "createView": "New",
+    "title": {
+      "placeholder": "Untitled"
+    },
     "settings": {
       "filter": "Filter",
       "sort": "Sort",
@@ -457,7 +460,9 @@
       "copyProperty": "Copied property to clipboard",
       "count": "Count",
       "newRow": "New row",
-      "action": "Action"
+      "action": "Action",
+      "add": "Click add to below",
+      "drag": "Drag to move"
     },
     "selectOption": {
       "create": "Create",