mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 01:07:08 +08:00
530 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|