mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
61a31c90ee
commit
f70aef68be
@ -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',
|
||||
|
@ -73,6 +73,7 @@
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fileDropEnabled": false,
|
||||
"fullscreen": false,
|
||||
"height": 1200,
|
||||
"resizable": true,
|
||||
|
8
frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg
Normal file
8
frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg
Normal 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 |
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
export enum DragType {
|
||||
Row = 'row',
|
||||
Field = 'field',
|
||||
}
|
||||
|
||||
export enum DropPosition {
|
||||
Before = 0,
|
||||
After = 1,
|
||||
}
|
@ -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);
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
export * from './dnd.context';
|
||||
export * from './drag.hooks';
|
||||
export * from './drop.hooks';
|
||||
export {
|
||||
ScrollDirection,
|
||||
Edge,
|
||||
} from './utils';
|
@ -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;
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
export * from './constants';
|
||||
|
||||
export * from './dnd';
|
||||
|
||||
export * from './VirtualizedList';
|
||||
export * from './CellText';
|
@ -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({
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
@ -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 }}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
@ -0,0 +1,3 @@
|
||||
export const GridCalculateRow = () => {
|
||||
return null;
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
@ -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,3 +1,3 @@
|
||||
export * from './database.context';
|
||||
export * from './database.hooks';
|
||||
export * from './grid';
|
||||
export * from './Database';
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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 />;
|
||||
};
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user