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
This commit is contained in:
fangwufeng-v 2023-09-27 15:13:25 +08:00 committed by GitHub
parent 61a31c90ee
commit f70aef68be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1062 additions and 287 deletions

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 495 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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