Files
docmost/apps/client/src/ee/base/components/grid/grid-container.tsx
T

530 lines
20 KiB
TypeScript

import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
import { Table } from "@tanstack/react-table";
import {
observeWindowOffset,
observeWindowRect,
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
pendingTypeInsertAtom,
type FormulaEditorTarget,
type PendingTypeInsert,
} from "@/ee/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { GridGhostRows } from "./grid-ghost-rows";
import { SelectionActionBar } from "./selection-action-bar";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
// var(--base-row-height) from this constant so the rendered height can
// never drift from the virtualizer estimate.
const ROW_HEIGHT = 37;
const OVERSCAN = 25;
const GRID_ROOT_STYLE = {
"--base-row-height": `${ROW_HEIGHT}px`,
} as React.CSSProperties;
const ADD_COLUMN_TRACK_WIDTH = 40;
// Hoisted to module scope to avoid allocating a fresh options object on
// every GridContainer render. The function refs from virtual-core are
// stable; only the wrapper object identity matters for downstream
// memoization inside useVirtualizer.
const WINDOW_SCROLL_OPTIONS = {
observeElementRect: observeWindowRect as never,
observeElementOffset: observeWindowOffset as never,
scrollToFn: windowScroll as never,
} as const;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
/** true when a view filter with at least one condition is active; suppresses ghost rows */
isFiltered?: boolean;
/**
* What the virtualizer measures and what the StickyBand sticks to.
* Standalone passes a ref into the .tableScrollport wrapper; inline
* passes `window` since the page itself is the scroll container.
*/
scrollElement: HTMLElement | Window | null;
/**
* Rendered inside `[role=grid]` but ABOVE the sticky band, so it scrolls
* with the content while only the column-header row stays pinned. In
* inline mode BaseTable injects banner + toolbar here; standalone passes
* null (they render outside the scrollport instead).
*/
aboveBand?: React.ReactNode;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
pageId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
isFiltered,
scrollElement,
aboveBand,
}: GridContainerProps) {
const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const rowsContainerRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
useGridAutoScroll(bodyRef, pageId);
useRowAutoScroll(scrollElement, pageId);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const rowIdsRef = useRef(rowIds);
rowIdsRef.current = rowIds;
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
const editable = useBaseEditable();
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const editingCellRef = useRef(editingCell);
editingCellRef.current = editingCell;
const { selectionCount, clear: clearSelection } = useRowSelection(pageId);
const { deleteSelected } = useDeleteSelectedRows(pageId);
const { t } = useTranslation();
const [focusedCell, setFocusedCell] = useAtom(focusedCellAtomFamily(pageId)) as unknown as [FocusedCell, (val: FocusedCell) => void];
const focusedCellRef = useRef(focusedCell);
focusedCellRef.current = focusedCell;
const [, setActiveFormulaEditor] = useAtom(activeFormulaEditorAtomFamily(pageId)) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const [, setPendingTypeInsert] = useAtom(pendingTypeInsertAtom) as unknown as [PendingTypeInsert, (val: PendingTypeInsert) => void];
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Only act while an inline cell editor is open. Popover-based cells
// (select/status/person/page/date/file) self-dismiss via Mantine onChange.
// This handler's sole job is to commit an inline input editor
// (text/number/url/email) when the user clicks elsewhere, since clicking
// a non-focusable cell does not natively blur the input. Gating on
// editingCell also stops it from stealing focus from unrelated inputs.
if (!editingCellRef.current) return;
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;
// Blurring the input fires its onBlur -> commitOnce -> handleCommit,
// which also clears editingCell. No setEditingCell(null) needed here.
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body && typeof active.blur === "function") {
active.blur();
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, []);
useColumnResize(table, onResizeEnd ?? (() => {}));
// When the scroll container is the window (inline embed mode), the default
// Element-mode observers read scrollTop/scrollLeft, which Window does not
// have. Swap in the Window-mode observers so the virtualizer reads
// scrollY/scrollX instead. The Element-narrowed type is satisfied by an
// upcast on getScrollElement; virtual-core's runtime accepts Window when
// the observers do.
const isWindowScroll =
typeof window !== "undefined" && scrollElement === window;
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
// Rows are positioned inside .rowsContainer, which sits below the sticky
// band (and aboveBand content) within the scroll content. scrollMargin =
// the container's offset from the scroll content top, in both modes, so
// virtual indexing lines up with what is actually on screen.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const el = rowsContainerRef.current;
if (!el || !scrollElement) return;
const update = () => {
const rect = el.getBoundingClientRect();
if (isWindowScroll) {
setScrollMargin(rect.top + window.scrollY);
} else {
const scrollport = scrollElement as HTMLElement;
setScrollMargin(
rect.top -
scrollport.getBoundingClientRect().top +
scrollport.scrollTop,
);
}
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
// Outer page reflows (sidebar collapse, viewport resize) can move the
// grid without resizing it, so listen to window resize too.
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isWindowScroll, scrollElement]);
// Stable row-id keys: the direct-update element cache and measurement
// cache are keyed by item key, so index keys would go stale whenever rows
// are inserted or reordered above the viewport.
const getItemKey = useCallback(
(index: number) => rowIds[index] ?? index,
[rowIds],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
scrollMargin,
getItemKey,
directDomUpdates: true,
// 'position' (writes `top`), not 'transform': a transform on the row
// creates a containing block that breaks the position:sticky pinned
// cells inside it.
directDomUpdatesMode: "position",
...windowScrollOptions,
// virtual-core bug: on first attach _willUpdate calls
// _scrollToOffset(getScrollOffset()), which returns undefined when no
// initialOffset is provided. windowScroll then computes undefined + 0 = NaN,
// browsers coerce it to 0, and scrollY snaps to 0 when the embed mounts
// mid-page. Seeding initialOffset to the current scroll position makes
// the first _scrollToOffset a no-op.
initialOffset: isWindowScroll
? () => window.scrollY
: () =>
scrollElement instanceof HTMLElement ? scrollElement.scrollTop : 0,
});
const virtualItems = virtualizer.getVirtualItems();
const pinnedLeftWidth = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((c) => c.getIsPinned() === "left")
.reduce((sum, c) => sum + c.getSize(), 0),
[table],
);
const scrollCellIntoView = useCallback(
(coord: CellCoord, rowIndex: number) => {
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
requestAnimationFrame(() => {
const scroller = bodyRef.current;
const el = document.getElementById(
`base-cell-${coord.rowId}-${coord.propertyId}`,
);
if (!scroller || !el) return;
const cellRect = el.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const pinned = pinnedLeftWidth();
if (cellRect.left < scRect.left + pinned) {
scroller.scrollLeft -= scRect.left + pinned - cellRect.left;
} else if (cellRect.right > scRect.right) {
scroller.scrollLeft += cellRect.right - scRect.right;
}
});
},
[virtualizer, pinnedLeftWidth],
);
const openEditor = useCallback(
(coord: CellCoord) => {
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop) return;
if (prop.type === "checkbox") {
if (!editable) return;
const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId);
onCellUpdate(coord.rowId, coord.propertyId, !current);
return;
}
if (!editable) {
if (prop.type === "file") setEditingCell(coord);
return;
}
if (prop.type === "formula") {
setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId });
return;
}
if (isSystemPropertyType(prop.type)) return;
setEditingCell(coord);
},
[properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor],
);
const clearCell = useCallback(
(coord: CellCoord) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type)) return;
onCellUpdate(coord.rowId, coord.propertyId, null);
},
[editable, properties, onCellUpdate],
);
const beginTypeToEdit = useCallback(
(coord: CellCoord, char: string) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return;
if (["text", "number", "url", "email"].includes(prop.type)) {
setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char });
setEditingCell(coord);
} else {
openEditor(coord);
}
},
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
);
const prevEditingRef = useRef(editingCell);
useEffect(() => {
const prev = prevEditingRef.current;
prevEditingRef.current = editingCell;
if (prev && !editingCell) {
if (!focusedCellRef.current) setFocusedCell(prev);
const grid = bodyRef.current;
const active = document.activeElement;
if (grid && active && !grid.contains(active)) {
grid.focus({ preventScroll: true });
}
}
}, [editingCell, setFocusedCell]);
useEffect(() => {
const fc = focusedCellRef.current;
if (!fc) return;
const rowOk = rowIds.includes(fc.rowId);
const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId);
if (!rowOk || !colOk) setFocusedCell(null);
}, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]);
const handleGridFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (editingCellRef.current || focusedCellRef.current) return;
const firstRow = rowIdsRef.current[0];
const firstCol = table
.getVisibleLeafColumns()
.find((c) => c.id !== "__row_number")?.id;
if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol });
},
[table, setFocusedCell],
);
useGridKeyboardNav({
table,
properties,
containerRef: bodyRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
});
const activeCell = editingCell ?? focusedCell;
const activeDescendantId = activeCell
? `base-cell-${activeCell.rowId}-${activeCell.propertyId}`
: undefined;
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 row set shrinks (filter/sort/view change) or resets to zero,
// un-gate the trigger so the first page can trigger the next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return (
columnWidths.join(" ") +
(pageId && editable ? ` ${ADD_COLUMN_TRACK_WIDTH}px` : "")
);
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable]);
const totalColumnsWidth = useMemo(
() =>
table
.getVisibleLeafColumns()
.reduce((sum, col) => sum + col.getSize(), 0) +
(pageId && editable ? ADD_COLUMN_TRACK_WIDTH : 0),
[table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable],
);
const showGhostRows = rows.length === 0 && !isFiltered;
// Append a flexible trailing track so every row spans the full width.
// minmax(0, 1fr) collapses to 0 when columns overflow the viewport and
// fills remaining width otherwise. The header grid keeps the plain template.
const bodyGridTemplateColumns = `${gridTemplateColumns} minmax(0, 1fr)`;
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({
left: bodyRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const getColumnOrder = useCallback(
() => table.getState().columnOrder,
[table],
);
return (
<div style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
</div>
</div>
<GridRowOrderProvider value={getOrderedRowIds}>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
role="grid"
aria-label={t("Base table")}
aria-rowcount={rows.length}
aria-colcount={table.getVisibleLeafColumns().length}
aria-multiselectable
aria-activedescendant={activeDescendantId}
onFocus={handleGridFocus}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
} as React.CSSProperties
}
>
<div
className={classes.rowsContainer}
ref={(node) => {
rowsContainerRef.current = node;
virtualizer.containerRef(node);
}}
role="rowgroup"
style={{ width: totalColumnsWidth, minWidth: "100%" }}
>
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
measureRef={virtualizer.measureElement}
onCellUpdate={onCellUpdate}
properties={properties}
columnVisibility={table.getState().columnVisibility}
columnOrder={table.getState().columnOrder}
pageId={pageId}
onRowReorder={onRowReorder}
/>
);
})}
</div>
{showGhostRows && (
<GridGhostRows
count={3}
columnCount={table.getVisibleLeafColumns().length}
onCreate={editable ? handleAddRow : undefined}
/>
)}
{editable && <AddRowButton onClick={handleAddRow} />}
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</GridRowOrderProvider>
</div>
);
}