mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 22:41:30 +08:00
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import { useRef, useMemo, useCallback, useEffect } from "react";
|
|
import { Table } from "@tanstack/react-table";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { useAtom } from "jotai";
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
horizontalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
|
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
|
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
|
|
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
|
|
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
|
|
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
|
|
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
|
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
|
|
import { GridHeader } from "./grid-header";
|
|
import { GridRow } from "./grid-row";
|
|
import { AddRowButton } from "./add-row-button";
|
|
import { SelectionActionBar } from "./selection-action-bar";
|
|
import classes from "@/features/base/styles/grid.module.css";
|
|
|
|
const ROW_HEIGHT = 36;
|
|
const OVERSCAN = 10;
|
|
|
|
type GridContainerProps = {
|
|
table: Table<IBaseRow>;
|
|
properties: IBaseProperty[];
|
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
|
onAddRow?: () => void;
|
|
baseId?: string;
|
|
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
|
onResizeEnd?: () => void;
|
|
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
|
hasNextPage?: boolean;
|
|
isFetchingNextPage?: boolean;
|
|
onFetchNextPage?: () => void;
|
|
};
|
|
|
|
export function GridContainer({
|
|
table,
|
|
properties,
|
|
onCellUpdate,
|
|
onAddRow,
|
|
baseId,
|
|
onColumnReorder,
|
|
onResizeEnd,
|
|
onRowReorder,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
onFetchNextPage,
|
|
}: GridContainerProps) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
// Records the `rows.length` at which we last triggered a page fetch.
|
|
// The trigger effect re-runs on every render (its `virtualItems` dep
|
|
// has a new identity each call) and can't rely on `isFetchingNextPage`
|
|
// alone: once a page commits, `isFetchingNextPage` flips to false for
|
|
// one render, the "near bottom" condition still holds because the
|
|
// virtualizer anchors on the old scroll position, and we'd fire again.
|
|
// Gating on `rows.length` guarantees at most one fire per new page.
|
|
const lastTriggeredRowsLenRef = useRef(0);
|
|
const rows = table.getRowModel().rows;
|
|
|
|
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
|
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
|
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
|
|
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
|
|
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
|
|
propertyMenuDirtyRef.current = propertyMenuDirty;
|
|
const closeRequestCounterRef = useRef(0);
|
|
|
|
const { selectionCount, clear: clearSelection } = useRowSelection();
|
|
const { deleteSelected } = useDeleteSelectedRows(baseId ?? "");
|
|
|
|
useEffect(() => {
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest(`.${classes.headerCell}`)) return;
|
|
if (target.closest("[role=\"dialog\"]")) return;
|
|
if (target.closest("[role=\"listbox\"]")) return;
|
|
if (target.closest("[data-mantine-shared-portal-node]")) return;
|
|
if (target.closest(`.${classes.cellEditing}`)) return;
|
|
if (propertyMenuDirtyRef.current) {
|
|
closeRequestCounterRef.current += 1;
|
|
setCloseRequest(closeRequestCounterRef.current);
|
|
} else {
|
|
setActivePropertyMenu(null);
|
|
}
|
|
setEditingCell(null);
|
|
};
|
|
document.addEventListener("mousedown", handleMouseDown);
|
|
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
}, [setActivePropertyMenu, setEditingCell, setCloseRequest]);
|
|
|
|
useColumnResize(table, onResizeEnd ?? (() => {}));
|
|
|
|
useGridKeyboardNav({
|
|
table,
|
|
editingCell,
|
|
setEditingCell,
|
|
containerRef: scrollRef,
|
|
});
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: rows.length,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: OVERSCAN,
|
|
});
|
|
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
|
|
useEffect(() => {
|
|
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
|
|
const lastItem = virtualItems[virtualItems.length - 1];
|
|
if (!lastItem) return;
|
|
if (lastItem.index < rows.length - OVERSCAN * 2) return;
|
|
if (rows.length <= lastTriggeredRowsLenRef.current) return;
|
|
lastTriggeredRowsLenRef.current = rows.length;
|
|
onFetchNextPage();
|
|
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
|
|
|
|
useEffect(() => {
|
|
// When the underlying row set shrinks (filter changed, sort toggled,
|
|
// view switched) or resets to zero, we're on a fresh pagination
|
|
// sequence — un-gate the trigger so the first page triggers a
|
|
// potential next fetch correctly.
|
|
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
|
|
lastTriggeredRowsLenRef.current = 0;
|
|
}
|
|
}, [rows.length]);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el || !baseId) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (editingCell) return;
|
|
const active = document.activeElement as HTMLElement | null;
|
|
if (!active || !el.contains(active)) return;
|
|
const tag = active.tagName;
|
|
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
|
|
return;
|
|
}
|
|
if (e.key === "Escape" && selectionCount > 0) {
|
|
clearSelection();
|
|
return;
|
|
}
|
|
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
|
|
e.preventDefault();
|
|
void deleteSelected();
|
|
}
|
|
};
|
|
el.addEventListener("keydown", handler);
|
|
return () => el.removeEventListener("keydown", handler);
|
|
}, [editingCell, selectionCount, clearSelection, deleteSelected, baseId]);
|
|
|
|
const gridTemplateColumns = useMemo(() => {
|
|
const visibleColumns = table.getVisibleLeafColumns();
|
|
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
|
return columnWidths.join(" ") + (baseId ? " 40px" : "");
|
|
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
|
|
|
|
const totalHeight = virtualizer.getTotalSize();
|
|
|
|
const paddingTop =
|
|
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
|
|
const paddingBottom =
|
|
virtualItems.length > 0
|
|
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
|
: 0;
|
|
|
|
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
|
|
|
const handleRowReorder = useCallback(
|
|
(rowId: string, targetRowId: string, position: "above" | "below") => {
|
|
onRowReorder?.(rowId, targetRowId, position);
|
|
},
|
|
[onRowReorder],
|
|
);
|
|
|
|
const {
|
|
dragState: rowDragState,
|
|
handleDragStart: handleRowDragStart,
|
|
handleDragOver: handleRowDragOver,
|
|
handleDragEnd: handleRowDragEnd,
|
|
handleDragLeave: handleRowDragLeave,
|
|
} = useRowDrag({ rowIds, onReorder: handleRowReorder });
|
|
|
|
const handleAddRow = useCallback(() => {
|
|
onAddRow?.();
|
|
}, [onAddRow]);
|
|
|
|
const handlePropertyCreated = useCallback(() => {
|
|
// Wait for React to re-render with the new column, then scroll to it
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
scrollRef.current?.scrollTo({
|
|
left: scrollRef.current.scrollWidth,
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
}),
|
|
useSensor(KeyboardSensor),
|
|
);
|
|
|
|
const sortableColumnIds = useMemo(() => {
|
|
return table
|
|
.getVisibleLeafColumns()
|
|
.filter((col) => col.id !== "__row_number")
|
|
.map((col) => col.id);
|
|
}, [table, table.getState().columnOrder]);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
onColumnReorder?.(active.id as string, over.id as string);
|
|
},
|
|
[onColumnReorder],
|
|
);
|
|
|
|
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
modifiers={modifiers}
|
|
>
|
|
<div
|
|
className={classes.gridWrapper}
|
|
ref={scrollRef}
|
|
tabIndex={0}
|
|
>
|
|
<div
|
|
className={classes.grid}
|
|
style={{ gridTemplateColumns }}
|
|
role="grid"
|
|
>
|
|
<SortableContext
|
|
items={sortableColumnIds}
|
|
strategy={horizontalListSortingStrategy}
|
|
>
|
|
<GridHeader
|
|
table={table}
|
|
baseId={baseId}
|
|
columnOrder={table.getState().columnOrder}
|
|
columnVisibility={table.getState().columnVisibility}
|
|
properties={properties}
|
|
loadedRowIds={rowIds}
|
|
onPropertyCreated={handlePropertyCreated}
|
|
/>
|
|
</SortableContext>
|
|
|
|
{paddingTop > 0 && (
|
|
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
|
|
)}
|
|
|
|
{virtualItems.map((virtualRow) => {
|
|
const row = rows[virtualRow.index];
|
|
if (!row) return null;
|
|
return (
|
|
<GridRow
|
|
key={row.id}
|
|
row={row}
|
|
rowIndex={virtualRow.index}
|
|
onCellUpdate={onCellUpdate}
|
|
orderedRowIds={rowIds}
|
|
columnVisibility={table.getState().columnVisibility}
|
|
dragHandlers={
|
|
onRowReorder
|
|
? {
|
|
onDragStart: handleRowDragStart,
|
|
onDragOver: handleRowDragOver,
|
|
onDragEnd: handleRowDragEnd,
|
|
onDragLeave: handleRowDragLeave,
|
|
isDragging: rowDragState.dragRowId === row.id,
|
|
isDropTarget: rowDragState.dropTargetRowId === row.id,
|
|
dropPosition: rowDragState.dropTargetRowId === row.id ? rowDragState.dropPosition : null,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{paddingBottom > 0 && (
|
|
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
|
|
)}
|
|
|
|
<AddRowButton onClick={handleAddRow} />
|
|
{baseId && <SelectionActionBar baseId={baseId} />}
|
|
</div>
|
|
</div>
|
|
</DndContext>
|
|
);
|
|
}
|