teableio/teable

View on GitHub
packages/sdk/src/components/grid/Grid.tsx

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex */
import { uniqueId } from 'lodash';
import type { CSSProperties, ForwardRefRenderFunction } from 'react';
import { useState, useRef, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
import { useRafState } from 'react-use';
import type { IGridTheme } from './configs';
import {
  gridTheme,
  GRID_DEFAULT,
  DEFAULT_SCROLL_STATE,
  DEFAULT_MOUSE_STATE,
  GRID_CONTAINER_ID,
} from './configs';
import { useResizeObserver } from './hooks';
import type { ScrollerRef } from './InfiniteScroller';
import { InfiniteScroller } from './InfiniteScroller';
import type { IInteractionLayerRef } from './InteractionLayer';
import { InteractionLayer } from './InteractionLayer';
import type {
  IRectangle,
  IScrollState,
  ICellItem,
  IGridColumn,
  IMouseState,
  IPosition,
  IRowControlItem,
  IColumnStatistics,
  ICollaborator,
  IGroupPoint,
  ILinearRow,
  IGroupCollection,
  DragRegionType,
} from './interface';
import {
  RegionType,
  RowControlType,
  DraggableType,
  SelectableType,
  LinearRowType,
} from './interface';
import type { ISpriteMap, CombinedSelection, IIndicesMap } from './managers';
import { CoordinateManager, SpriteManager, ImageManager } from './managers';
import { getCellRenderer, type ICell, type IInnerCell } from './renderers';
import { TouchLayer } from './TouchLayer';
import { measuredCanvas } from './utils';

export interface IGridExternalProps {
  theme?: Partial<IGridTheme>;
  customIcons?: ISpriteMap;
  rowControls?: IRowControlItem[];
  smoothScrollX?: boolean;
  smoothScrollY?: boolean;
  scrollBufferX?: number;
  scrollBufferY?: number;
  scrollBarVisible?: boolean;
  rowIndexVisible?: boolean;
  collaborators?: ICollaborator;

  /**
   * Indicates which areas can be dragged, including rows, columns or no drag
   * - 'all': Allow drag of rows, columns and cells (default)
   * - 'none': Disable drag for all areas
   * - 'row': Allow row drag only
   * - 'column': Allow column drag only
   */
  draggable?: DraggableType;

  /**
   * Indicates which areas can be selected, including row selection,
   * column selection, cell selection, all areas, or no selection
   * - 'all': Allow selection of rows, columns and cells (default)
   * - 'none': Disable selection for all areas
   * - 'row': Allow row selection only
   * - 'column': Allow column selection only
   * - 'cell': Allow cell selection only
   */
  selectable?: SelectableType;

  /**
   * Whether to allow multiple selection operations, including rows, columns and cells
   * If true, allow multiple selection of rows/columns/cells (default)
   * If false, disable multiple selection operations
   * @type {boolean}
   */
  isMultiSelectionEnable?: boolean;

  groupCollection?: IGroupCollection | null;
  collapsedGroupIds?: Set<string> | null;
  groupPoints?: IGroupPoint[] | null;

  onUndo?: () => void;
  onRedo?: () => void;
  onCopy?: (selection: CombinedSelection) => void;
  onPaste?: (selection: CombinedSelection, e: React.ClipboardEvent) => void;
  onDelete?: (selection: CombinedSelection) => void;
  onCellEdited?: (cell: ICellItem, newValue: IInnerCell) => void;
  onSelectionChanged?: (selection: CombinedSelection) => void;
  onVisibleRegionChanged?: (rect: IRectangle) => void;
  onCollapsedGroupChanged?: (collapsedGroupIds: Set<string>) => void;
  onColumnFreeze?: (freezeColumnCount: number) => void;
  onColumnAppend?: () => void;
  onRowExpand?: (rowIndex: number) => void;
  onRowAppend?: (targetIndex?: number) => void;
  onRowOrdered?: (dragRowIndexCollection: number[], dropRowIndex: number) => void;
  onColumnOrdered?: (dragColIndexCollection: number[], dropColIndex: number) => void;
  onColumnResize?: (column: IGridColumn, newSize: number, colIndex: number) => void;
  onColumnHeaderClick?: (colIndex: number, bounds: IRectangle) => void;
  onColumnHeaderDblClick?: (colIndex: number, bounds: IRectangle) => void;
  onColumnHeaderMenuClick?: (colIndex: number, bounds: IRectangle) => void;
  onColumnStatisticClick?: (colIndex: number, bounds: IRectangle) => void;
  onContextMenu?: (selection: CombinedSelection, position: IPosition) => void;
  onScrollChanged?: (scrollLeft: number, scrollTop: number) => void;
  onDragStart?: (type: DragRegionType, dragIndexs: number[]) => void;

  /**
   * Triggered when the mouse hovers over the every type of region
   */
  onItemHovered?: (type: RegionType, bounds: IRectangle, cellItem: ICellItem) => void;

  /**
   * Triggered when the mouse clicks the every type of region
   */
  onItemClick?: (type: RegionType, bounds: IRectangle, cellItem: ICellItem) => void;
}

export interface IGridProps extends IGridExternalProps {
  columns: IGridColumn[];
  commentCountMap?: Record<string, number>;
  freezeColumnCount?: number;
  rowCount: number;
  rowHeight?: number;
  style?: CSSProperties;
  isTouchDevice?: boolean;
  columnHeaderVisible?: boolean;
  columnStatistics?: IColumnStatistics;
  getCellContent: (cell: ICellItem) => ICell;
}

export interface IGridRef {
  resetState: () => void;
  forceUpdate: () => void;
  getActiveCell: () => ICellItem | null;
  getRowOffset: (rowIndex: number) => number;
  setSelection: (selection: CombinedSelection) => void;
  getScrollState: () => IScrollState;
  scrollBy: (deltaX: number, deltaY: number) => void;
  scrollTo: (scrollLeft?: number, scrollTop?: number) => void;
  scrollToItem: (position: [columnIndex: number, rowIndex: number]) => void;
}

const {
  scrollBuffer,
  appendRowHeight,
  groupHeaderHeight,
  cellScrollBuffer,
  columnAppendBtnWidth,
  columnStatisticHeight,
  rowHeight: defaultRowHeight,
  columnWidth: defaultColumnWidth,
  columnHeadHeight: defaultColumnHeaderHeight,
} = GRID_DEFAULT;

const GridBase: ForwardRefRenderFunction<IGridRef, IGridProps> = (props, forwardRef) => {
  const {
    columns,
    commentCountMap,
    groupCollection,
    collapsedGroupIds,
    draggable = DraggableType.All,
    selectable = SelectableType.All,
    columnStatistics,
    freezeColumnCount: _freezeColumnCount = 1,
    rowCount: originRowCount,
    rowHeight = defaultRowHeight,
    rowControls = [{ type: RowControlType.Checkbox }],
    theme: customTheme,
    isTouchDevice,
    smoothScrollX = true,
    smoothScrollY = true,
    scrollBufferX = scrollBuffer,
    scrollBufferY = scrollBuffer,
    scrollBarVisible = true,
    rowIndexVisible = true,
    isMultiSelectionEnable = true,
    style,
    customIcons,
    collaborators,
    groupPoints,
    columnHeaderVisible = true,
    getCellContent,
    onUndo,
    onRedo,
    onCopy,
    onPaste,
    onDelete,
    onRowAppend,
    onRowExpand,
    onRowOrdered,
    onCellEdited,
    onColumnAppend,
    onColumnResize,
    onColumnOrdered,
    onDragStart,
    onContextMenu,
    onSelectionChanged,
    onVisibleRegionChanged,
    onColumnFreeze,
    onColumnHeaderClick,
    onColumnHeaderDblClick,
    onColumnHeaderMenuClick,
    onColumnStatisticClick,
    onCollapsedGroupChanged,
    onItemHovered,
    onItemClick,
    onScrollChanged,
  } = props;

  useImperativeHandle(forwardRef, () => ({
    resetState: () => interactionLayerRef.current?.resetState(),
    forceUpdate: () => setForceRenderFlag(uniqueId('grid_')),
    getActiveCell: () => activeCell,
    setSelection: (selection: CombinedSelection) => {
      interactionLayerRef.current?.setSelection(selection);
    },
    getRowOffset: (rowIndex: number) => {
      const { scrollTop } = scrollState;
      const realRowIndex = real2RowIndex(rowIndex);
      return coordInstance.getRowOffset(realRowIndex) - scrollTop;
    },
    scrollBy,
    scrollTo,
    scrollToItem,
    getScrollState: () => scrollState,
  }));

  const hasAppendRow = onRowAppend != null;
  const hasAppendColumn = onColumnAppend != null;
  const rowControlCount = rowControls.length;
  const totalWidth = columns.reduce(
    (prev, column) => prev + (column.width || defaultColumnWidth),
    hasAppendColumn ? scrollBufferX + columnAppendBtnWidth : scrollBufferX
  );

  const [forceRenderFlag, setForceRenderFlag] = useState(uniqueId('grid_'));
  const [mouseState, setMouseState] = useState<IMouseState>(DEFAULT_MOUSE_STATE);
  const [scrollState, setScrollState] = useState<IScrollState>(DEFAULT_SCROLL_STATE);
  const [activeCell, setActiveCell] = useRafState<ICellItem | null>(null);
  const scrollerRef = useRef<ScrollerRef | null>(null);
  const containerRef = useRef<HTMLDivElement | null>(null);
  const interactionLayerRef = useRef<IInteractionLayerRef | null>(null);
  const { ref, width, height } = useResizeObserver<HTMLDivElement>();

  const [activeColumnIndex, activeRowIndex] = activeCell ?? [];
  const hoverRegionType = mouseState.type;
  const hasColumnStatistics = columnStatistics != null;
  const containerHeight = hasColumnStatistics ? height - columnStatisticHeight : height;
  const columnCount = columns.length;
  const freezeColumnCount = Math.min(_freezeColumnCount, columnCount);

  const theme = useMemo(() => ({ ...gridTheme, ...customTheme }), [customTheme]);
  const { iconSizeMD } = theme;

  const columnInitSize = useMemo(() => {
    return !rowIndexVisible && !rowControlCount ? 0 : Math.max(rowControlCount, 2) * iconSizeMD;
  }, [rowControlCount, rowIndexVisible, iconSizeMD]);

  const defaultRowsInfo = useMemo(() => {
    return {
      linearRows: [],
      real2LinearRowMap: null,
      pureRowCount: originRowCount,
      rowCount: hasAppendRow ? originRowCount + 1 : originRowCount,
      rowHeightMap: hasAppendRow ? { [originRowCount]: appendRowHeight } : undefined,
    };
  }, [hasAppendRow, originRowCount]);

  // eslint-disable-next-line sonarjs/cognitive-complexity
  const groupRowsInfo = useMemo(() => {
    if (!groupPoints?.length) return null;
    let rowIndex = 0;
    let totalIndex = 0;
    let currentValue: unknown = null;
    let collapsedDepth = Number.MAX_VALUE;
    const linearRows: ILinearRow[] = [];
    const rowHeightMap: IIndicesMap = {};
    const real2LinearRowMap: Record<number, number> = {};

    groupPoints.forEach((point) => {
      const { type } = point;
      if (type === LinearRowType.Group) {
        const { id, value, depth, isCollapsed } = point;
        const isSubGroup = depth > collapsedDepth;

        if (isCollapsed) {
          collapsedDepth = Math.min(collapsedDepth, depth);
          if (isSubGroup) return;
        } else if (!isSubGroup) {
          collapsedDepth = Number.MAX_VALUE;
        } else {
          return;
        }

        rowHeightMap[totalIndex] = groupHeaderHeight;
        linearRows.push({
          id,
          type: LinearRowType.Group,
          depth,
          value,
          realIndex: rowIndex,
          isCollapsed: Boolean(isCollapsed),
        });
        currentValue = value;
        totalIndex++;
      }
      if (type === LinearRowType.Row) {
        const count = point.count;

        for (let i = 0; i < count; i++) {
          real2LinearRowMap[rowIndex + i] = totalIndex + i;
          linearRows.push({
            type: LinearRowType.Row,
            displayIndex: i + 1,
            realIndex: rowIndex + i,
          });
        }

        rowIndex += count;
        totalIndex += count;

        if (hasAppendRow) {
          rowHeightMap[totalIndex] = appendRowHeight;
          linearRows.push({
            type: LinearRowType.Append,
            value: currentValue,
            realIndex: rowIndex - 1,
          });
          totalIndex++;
        }
      }
    });

    return {
      linearRows,
      real2LinearRowMap,
      pureRowCount: rowIndex,
      rowCount: totalIndex,
      rowHeightMap,
    };
  }, [groupPoints, hasAppendRow]);

  const { rowCount, pureRowCount, rowHeightMap, linearRows, real2LinearRowMap } = useMemo(() => {
    return { ...defaultRowsInfo, ...groupRowsInfo };
  }, [defaultRowsInfo, groupRowsInfo]);

  const getLinearRow = useCallback(
    (index: number) => {
      if (!linearRows.length) {
        return (
          index >= pureRowCount
            ? {
                type: LinearRowType.Append,
                realIndex: index - 1,
                value: null,
              }
            : {
                type: LinearRowType.Row,
                displayIndex: index + 1,
                realIndex: index,
              }
        ) as ILinearRow;
      }
      return linearRows[index] ?? { realIndex: -2 };
    },
    [linearRows, pureRowCount]
  );

  const real2RowIndex = useCallback(
    (index: number) => {
      if (real2LinearRowMap == null) return index;
      return real2LinearRowMap[index];
    },
    [real2LinearRowMap]
  );

  const columnWidthMap = useMemo(() => {
    return columns.reduce(
      (acc, column, index) => ({
        ...acc,
        [index]: column.width || defaultColumnWidth,
      }),
      {}
    );
  }, [columns]);

  const coordInstance = useMemo<CoordinateManager>(() => {
    return new CoordinateManager({
      rowHeight,
      columnWidth: defaultColumnWidth,
      pureRowCount,
      rowCount,
      columnCount,
      freezeColumnCount,
      containerWidth: width,
      containerHeight,
      rowInitSize: columnHeaderVisible ? defaultColumnHeaderHeight : 0,
      columnInitSize,
      rowHeightMap,
      columnWidthMap,
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rowHeight, pureRowCount, rowCount, rowHeightMap, columnHeaderVisible]);

  const totalHeight = coordInstance.totalHeight + scrollBufferY;

  useMemo(() => {
    coordInstance.refreshColumnDimensions({ columnInitSize, columnCount, columnWidthMap });
    setForceRenderFlag(uniqueId('grid_'));
  }, [coordInstance, columnInitSize, columnCount, columnWidthMap]);

  useMemo(() => {
    coordInstance.containerWidth = width;
    coordInstance.containerHeight = containerHeight;
    coordInstance.freezeColumnCount = freezeColumnCount;
    setForceRenderFlag(uniqueId('grid_'));
  }, [coordInstance, width, containerHeight, freezeColumnCount]);

  const activeCellBound = useMemo(() => {
    if (activeColumnIndex == null || activeRowIndex == null) {
      return null;
    }

    const cell = getCellContent([activeColumnIndex, activeRowIndex]);
    const cellRenderer = getCellRenderer(cell.type);
    const originWidth = coordInstance.getColumnWidth(activeColumnIndex);
    const originHeight = coordInstance.getRowHeight(real2RowIndex(activeRowIndex));

    if (cellRenderer?.measure && measuredCanvas?.ctx != null) {
      const { width, height, totalHeight } = cellRenderer.measure(cell as never, {
        theme,
        ctx: measuredCanvas.ctx,
        width: originWidth,
        height: originHeight,
      });
      return {
        rowIndex: activeRowIndex,
        columnIndex: activeColumnIndex,
        width,
        height,
        totalHeight,
        scrollTop: 0,
        scrollEnable: totalHeight > height,
      };
    }
    return {
      rowIndex: activeRowIndex,
      columnIndex: activeColumnIndex,
      width: originWidth,
      height: originHeight,
      totalHeight: originHeight,
      scrollTop: 0,
      scrollEnable: false,
    };
  }, [activeColumnIndex, activeRowIndex, coordInstance, theme, getCellContent, real2RowIndex]);

  const scrollEnable =
    hoverRegionType !== RegionType.None &&
    !(hoverRegionType === RegionType.ActiveCell && activeCellBound?.scrollEnable);

  const spriteManager = useMemo(
    () => new SpriteManager(customIcons, () => setForceRenderFlag(uniqueId('grid_'))),
    [customIcons]
  );

  const imageManager = useMemo<ImageManager>(() => {
    const imgManager = new ImageManager();
    imgManager.setCallback(() => setForceRenderFlag(uniqueId('grid_')));
    return imgManager;
  }, []);

  const scrollTo = useCallback((sl?: number, st?: number) => {
    scrollerRef.current?.scrollTo(sl, st);
  }, []);

  const scrollBy = useCallback((deltaX: number, deltaY: number) => {
    scrollerRef.current?.scrollBy(deltaX, deltaY);
  }, []);

  const scrollToItem = useCallback(
    (position: [columnIndex: number, rowIndex: number]) => {
      const { containerHeight, containerWidth, freezeRegionWidth, freezeColumnCount, rowInitSize } =
        coordInstance;
      const { scrollTop, scrollLeft } = scrollState;
      const [columnIndex, _rowIndex] = position;
      const rowIndex = real2RowIndex(_rowIndex);
      const isFreezeColumn = columnIndex < freezeColumnCount;

      if (!isFreezeColumn) {
        const offsetX = coordInstance.getColumnOffset(columnIndex);
        const columnWidth = coordInstance.getColumnWidth(columnIndex);
        const deltaLeft = Math.min(offsetX - scrollLeft - freezeRegionWidth, 0);
        const deltaRight = Math.max(offsetX + columnWidth - scrollLeft - containerWidth, 0);
        const sl = scrollLeft + deltaLeft + deltaRight;
        if (sl !== scrollLeft) {
          const scrollBuffer =
            deltaLeft < 0 ? -cellScrollBuffer : deltaRight > 0 ? cellScrollBuffer : 0;
          scrollTo(sl + scrollBuffer, undefined);
        }
      }

      const rowHeight = coordInstance.getRowHeight(rowIndex);
      const offsetY = coordInstance.getRowOffset(rowIndex);
      const deltaTop = Math.min(offsetY - scrollTop - rowInitSize, 0);
      const deltaBottom = Math.max(offsetY + rowHeight - scrollTop - containerHeight, 0);
      const st = scrollTop + deltaTop + deltaBottom;
      if (st !== scrollTop) {
        scrollTo(undefined, st);
      }
    },
    [coordInstance, scrollState, scrollTo, real2RowIndex]
  );

  const onMouseDown = () => {
    containerRef.current?.focus();
  };

  const { rowInitSize } = coordInstance;

  return (
    <div className="size-full" style={style} ref={ref}>
      <div
        id={GRID_CONTAINER_ID}
        ref={containerRef}
        tabIndex={0}
        className="relative outline-none"
        onMouseDown={onMouseDown}
      >
        {isTouchDevice ? (
          <TouchLayer
            width={width}
            height={height}
            theme={theme}
            columns={columns}
            commentCountMap={commentCountMap}
            mouseState={mouseState}
            scrollState={scrollState}
            rowControls={rowControls}
            collaborators={collaborators}
            imageManager={imageManager}
            spriteManager={spriteManager}
            coordInstance={coordInstance}
            columnStatistics={columnStatistics}
            collapsedGroupIds={collapsedGroupIds}
            columnHeaderVisible={columnHeaderVisible}
            forceRenderFlag={forceRenderFlag}
            rowIndexVisible={rowIndexVisible}
            groupCollection={groupCollection}
            getLinearRow={getLinearRow}
            real2RowIndex={real2RowIndex}
            getCellContent={getCellContent}
            setMouseState={setMouseState}
            setActiveCell={setActiveCell}
            onDelete={onDelete}
            onRowAppend={onRowAppend}
            onRowExpand={onRowExpand}
            onCellEdited={onCellEdited}
            onContextMenu={onContextMenu}
            onColumnAppend={onColumnAppend}
            onColumnHeaderClick={onColumnHeaderClick}
            onColumnStatisticClick={onColumnStatisticClick}
            onCollapsedGroupChanged={onCollapsedGroupChanged}
            onSelectionChanged={onSelectionChanged}
          />
        ) : (
          <InteractionLayer
            ref={interactionLayerRef}
            width={width}
            height={height}
            theme={theme}
            columns={columns}
            commentCountMap={commentCountMap}
            draggable={draggable}
            selectable={selectable}
            collaborators={collaborators}
            rowControls={rowControls}
            imageManager={imageManager}
            spriteManager={spriteManager}
            coordInstance={coordInstance}
            columnStatistics={columnStatistics}
            collapsedGroupIds={collapsedGroupIds}
            columnHeaderVisible={columnHeaderVisible}
            isMultiSelectionEnable={isMultiSelectionEnable}
            activeCell={activeCell}
            mouseState={mouseState}
            scrollState={scrollState}
            activeCellBound={activeCellBound}
            forceRenderFlag={forceRenderFlag}
            rowIndexVisible={rowIndexVisible}
            groupCollection={groupCollection}
            getLinearRow={getLinearRow}
            real2RowIndex={real2RowIndex}
            getCellContent={getCellContent}
            setMouseState={setMouseState}
            setActiveCell={setActiveCell}
            scrollToItem={scrollToItem}
            scrollBy={scrollBy}
            onUndo={onUndo}
            onRedo={onRedo}
            onCopy={onCopy}
            onPaste={onPaste}
            onDelete={onDelete}
            onDragStart={onDragStart}
            onRowAppend={onRowAppend}
            onRowExpand={onRowExpand}
            onRowOrdered={onRowOrdered}
            onCellEdited={onCellEdited}
            onContextMenu={onContextMenu}
            onColumnAppend={onColumnAppend}
            onColumnResize={onColumnResize}
            onColumnOrdered={onColumnOrdered}
            onColumnHeaderClick={onColumnHeaderClick}
            onColumnStatisticClick={onColumnStatisticClick}
            onColumnHeaderDblClick={onColumnHeaderDblClick}
            onColumnHeaderMenuClick={onColumnHeaderMenuClick}
            onCollapsedGroupChanged={onCollapsedGroupChanged}
            onSelectionChanged={onSelectionChanged}
            onColumnFreeze={onColumnFreeze}
            onItemHovered={onItemHovered}
            onItemClick={onItemClick}
          />
        )}
      </div>

      <InfiniteScroller
        ref={scrollerRef}
        coordInstance={coordInstance}
        top={rowInitSize}
        left={columnInitSize}
        containerWidth={width}
        containerHeight={containerHeight}
        scrollWidth={totalWidth}
        scrollHeight={totalHeight}
        smoothScrollX={smoothScrollX}
        smoothScrollY={smoothScrollY}
        scrollBarVisible={scrollBarVisible}
        containerRef={containerRef}
        scrollState={scrollState}
        scrollEnable={scrollEnable}
        getLinearRow={getLinearRow}
        setScrollState={setScrollState}
        onScrollChanged={onScrollChanged}
        onVisibleRegionChanged={onVisibleRegionChanged}
      />
    </div>
  );
};

export const Grid = forwardRef(GridBase);