封装效果

便于快速可选复用定制化交互及主题

封装内容

含左键单击、多选、刷选(圈选)、可配置合并单元格、合并单元格单击事件、右键单击事件等高度定制的交互。

部分v0.1.0代码

useS2Table.ts

import { ref } from "vue";
import { TableSheet, S2Event } from "@antv/s2";
import { debounce } from "lodash";
import { defaultOptions, themeOptions, CustomEvent } from "./config.ts";
import type { Ref } from "vue";
import type {
  Config,
  Cell,
  MergedCell,
  State,
  DefaultEventConfig,
} from "./types";
import type { S2Theme } from "@antv/s2";
import {
  getMergedCellsInfo,
  getFilteredCellsByName,
  getMergedRowCells,
  getRowCells,
  getRowCellsByClick,
  getRowDataByCells,
} from "./utils.ts";

export function useS2Table(
  containerRef: Ref<HTMLElement | null>,
  config: Ref<Config>
) {
  const s2Ref: Ref<TableSheet | null> = shallowRef(null); // 保存 s2 table 实例
  const wrapperSize: Ref<{ width: number; height: number }> = ref({
    width: 0,
    height: 0,
  });
  let _config: Config;

  // 手动设置尺寸
  const resizeTable = (
    width: number,
    height: number,
    reLoadData: boolean = false
  ) => {
    if (s2Ref.value) {
      s2Ref.value.changeSheetSize(width, height);
      s2Ref.value.render(reLoadData);
    }
  };

  // 强制刷新视图
  const renderTable = (reLoadData: boolean = false) => {
    if (s2Ref.value) {
      resizeTableByConfig(
        wrapperSize.value.width,
        wrapperSize.value.height,
        reLoadData
      );
    }
  };

  const resizeTableByConfig = (
    width: number,
    height: number,
    reLoadData: boolean = false
  ) => {
    if (s2Ref.value) {
      if (_config.customConfig?.resizeConfig?.calcResizeFunc) {
        const size = _config.customConfig?.resizeConfig?.calcResizeFunc(
          width,
          height
        );
        s2Ref.value.changeSheetSize(
          size?.width || width,
          size?.height || height
        );
      } else {
        s2Ref.value.changeSheetSize(width, height);
      }
      s2Ref.value.render(reLoadData);
    }
  };

  let debounceResize;

  const setTheme = (_themeOptions: S2Theme) => {
    s2Ref.value && s2Ref.value.setTheme(_themeOptions);
  };

  const getSelectedCells = () => {
    if (s2Ref.value) {
      const state = s2Ref.value.interaction.getState();
      return state.cells;
    }
  };

  // 用于获取选中的单元格所在的行的原始数据, 默认非响应性, 每行数据多加了个 $rowIndex 索引值
  const getSelectedRowsData = (_toRaw: boolean = true) => {
    if (s2Ref.value) {
      const state = s2Ref.value.interaction.getState();
      const ans = getRowDataByCells(state as unknown as State);
      return _toRaw ? JSON.parse(JSON.stringify(ans)) : ans;
    }
  };

  const initializeOptions = () => {
    _config.options = { ...defaultOptions, ..._config.options };
    if (
      _config.customConfig?.mergeConfig?.isOn &&
      _config.customConfig?.mergeConfig?.mergeKey &&
      typeof _config.customConfig?.mergeConfig?.colIndex === "number"
    ) {
      _config.options.mergedCellsInfo = getMergedCellsInfo(
        _config.dataConfig.data,
        _config.customConfig.mergeConfig.mergeKey,
        _config.customConfig?.mergeConfig?.colIndex
      );
    }
  };

  const initializeSettings = () => {
    const s2 = s2Ref.value;
    if (!s2) return;
    // 检查配置 看是否启用封装好的交互方法 并执行额外的回调函数
    if (
      _config.customConfig?.defaultEventsConfig &&
      _config.customConfig?.defaultEventsConfig.length > 0
    ) {
      _config.customConfig.defaultEventsConfig.forEach((e) => {
        switch (e.eventName) {
          // 数据单元格刷选事件
          case CustomEvent.DATA_CELL_BRUSH_SELECTION: {
            const _event: DefaultEventConfig<CustomEvent.DATA_CELL_BRUSH_SELECTION> =
              e;
            s2.on(S2Event.DATA_CELL_BRUSH_SELECTION, (targetCells) => {
              s2.interaction.changeState(
                getRowCells(
                  s2.interaction.getState() as State,
                  targetCells as unknown as Cell[]
                )
              );
              _event.callback && _event.callback(targetCells);
            });
            break;
          }
          // 数据单元格 click 事件
          case CustomEvent.DATA_CELL_CLICK: {
            const _event: DefaultEventConfig<CustomEvent.DATA_CELL_CLICK> = e;
            s2.on(S2Event.DATA_CELL_CLICK, (event) => {
              const cell = s2.getCell(event.target);
              s2.interaction.changeState(
                getRowCellsByClick(
                  s2.interaction.getState() as State,
                  cell as Cell,
                  event.originalEvent.shiftKey
                )
              );
              const cellRow =
                cell.spreadsheet.dataSet.originData[cell.meta.rowIndex];
              _event.callback && _event.callback(event, cell, cellRow);
            });
            break;
          }
          // 右键单击事件
          case CustomEvent.GLOBAL_CONTEXT_MENU: {
            const _event: DefaultEventConfig<CustomEvent.GLOBAL_CONTEXT_MENU> =
              e;
            s2.on(S2Event.GLOBAL_CONTEXT_MENU, (event) => {
              const cell = s2.getCell(event.target);
              s2.interaction.changeState(
                getFilteredCellsByName(
                  s2.interaction.getState() as State,
                  cell as unknown as Cell,
                  e.params?.field || "testStage"
                )
              );
              const cellRow =
                cell.spreadsheet.dataSet.originData[cell.meta.rowIndex];
              _event.callback && _event.callback(event, cell, cellRow);
            });
            break;
          }
          // 监听 mergedCell 的点击事件,自定义点击后的交互操作
          case CustomEvent.MERGED_CELLS_CLICK: {
            const _event: DefaultEventConfig<CustomEvent.MERGED_CELLS_CLICK> =
              e;
            s2.on(S2Event.MERGED_CELLS_CLICK, (event) => {
              const cell = s2.getCell(event.target);
              s2.interaction.changeState(
                getMergedRowCells(
                  cell as unknown as MergedCell,
                  _config.dataConfig.meta
                )
              );
              const cellRow =
                cell.spreadsheet.dataSet.originData[cell.meta.rowIndex];
              _event.callback && _event.callback(event, cell, cellRow);
            });
            break;
          }
          default:
            break;
        }
      });
    }
    // 检查配置 监听配置中指定的事件
    if (
      _config.customConfig?.eventsConfig &&
      _config.customConfig?.eventsConfig.length > 0
    ) {
      _config.customConfig?.eventsConfig.forEach((e) => {
        s2.on(e.eventName, e.callback);
      });
    }
  };

  const initializeTable = () => {
    if (containerRef.value && !s2Ref.value) {
      s2Ref.value = new TableSheet(
        containerRef.value,
        _config.dataConfig,
        _config.options
      );
      s2Ref.value.setTheme(themeOptions);
      s2Ref.value.render();

      // auto resize
      // const resizeWrapperElement = typeof _config.customConfig?.resizeConfig?.wrapper === 'string' ?
      //     document.getElementById(_config.customConfig?.resizeConfig?.wrapper) : _config.customConfig?.resizeConfig?.wrapper;
      const wrapperValue = _config.customConfig?.resizeConfig?.wrapper;
      const resizeWrapperElement = wrapperValue
        ? typeof wrapperValue === "string"
          ? document.getElementById(wrapperValue)
          : wrapperValue
        : containerRef.value;
      if (resizeWrapperElement) {
        new ResizeObserver(([entry] = []) => {
          const [size] = entry.borderBoxSize || [];
          debounceResize(size.inlineSize, size.blockSize);
          wrapperSize.value.width = size.inlineSize;
          wrapperSize.value.height = size.blockSize;
        }).observe(resizeWrapperElement);
      }

      initializeSettings();
    }
  };

  const initialize = () => {
    initializeOptions();
    initializeTable();
  };

  watch(
    [config],
    ([val]) => {
      _config = val;
      debounceResize = debounce(
        resizeTableByConfig,
        _config.customConfig?.resizeConfig?.debounceTime || 50
      );
      initialize();
    },
    { immediate: false }
  );
  return {
    s2Ref,
    setTheme,
    resizeTable,
    renderTable,
    getSelectedCells,
    getSelectedRowsData,
  };
}

utils.ts

import {Meta} from "@antv/s2";
import type {InteractionStateInfo} from "@antv/s2";
import type {MergedCell, Cell, CellMeta, State, CellState} from './types';

// 用于初始化时 将指定列 相邻相同内容的单元格合并
export function getMergedCellsInfo(data: Object[], mergeKey: string, colIndex: number) {
    let ans: any[] = [];
    let k: number = -1;
    let lastValue: string = '';
    data.forEach((obj: Record<string, any>, index) => {
        if (obj[mergeKey] !== lastValue) {
            // 单格不变为 "合并单元格" 类型
            // if (lastValue !== '' && index - 1 > k) {
            // 单格也变为 "合并单元格" 类型
            if (lastValue !== '' && index - 1 >= k) {
                ans.push(generateArray(k, index - 1, colIndex));
                k = index;
                lastValue = obj[mergeKey];
            } else {
                k = index;
                lastValue = obj[mergeKey];
            }
        }
        if (index === data.length - 1 && index > k) {
            ans.push(generateArray(k, index, colIndex));
        }
    });
    return ans;
}

function generateArray(kA: number, kB: number, colIndex: number) {
    const result = [];
    for (let i = kA; i <= kB; i++) {
        result.push({colIndex: colIndex, rowIndex: i});
    }
    return result;
}

// 用于获取指定合并单元格所在行的所有单元格
export function getMergedRowCells(mergedCell: MergedCell, columns: Meta[]): InteractionStateInfo {
    let ans: CellMeta[] = [];
    mergedCell.cells.forEach((cell) => {
        let rowIndex = cell.meta.rowIndex;
        columns.forEach((column, index) => {
            ans.push({
                colIndex: index,
                rowIndex,
                type: 'dataCell',
                id: `${rowIndex}-root[&]${column.name}`,
            });
        })
    });

    return {
        stateName: 'selected',
        force: true,
        cells: ans,
    } as InteractionStateInfo;
}

// 用于过滤单元格组 (保留和指定单元格的指定字段相同的单元格)
export function getFilteredCellsByName(oldState: State, cell: Cell, field: string = 'testStage'): InteractionStateInfo {
    let ans: CellMeta[] = [];
    let failedRowIndexSet = new Set<number>();
    const oldCells = oldState.cells;
    const rowIndex = cell.meta.rowIndex;
    const data = cell.meta.spreadsheet?.dataSet.displayData;
    if (data) {
        const filterValue = data[rowIndex][field];
        if (filterValue) {
            oldCells.forEach((c) => {
                if (failedRowIndexSet.has(c.rowIndex) || data[c.rowIndex][field] !== filterValue) {
                    failedRowIndexSet.add(c.rowIndex);
                } else {
                    ans.push(c);
                }
            });
        }
    }

    return {
        stateName: 'selected',
        force: true,
        cells: ans,
    } as InteractionStateInfo;
}

// 用于获取选中的单元格所在的行的原始数据, 另外每行数据多加了个 $rowIndex 索引值
export function getRowDataByCells(state: State) {
    let ans: Object[] = [];
    let selectedRowIndexSet: Set<number> = new Set<number>();
    const cells: CellState[] = state.cells;
    if (state.interactedCells && state.interactedCells.length > 0) {
        const originData = state.interactedCells[0].spreadsheet.dataSet.originData;

        cells.forEach((cell) => {
            selectedRowIndexSet.add(cell.rowIndex);
        });

        Array.from(selectedRowIndexSet).forEach((rowIndex) => {
            originData[rowIndex].$rowIndex = rowIndex;
            ans.push(originData[rowIndex]);
        });
    }
    return ans;
}

// 用于将选中的单元格扩展到行粒度(支持刷选/圈选)
export function getRowCells(oldState: State, cells: Cell[]): InteractionStateInfo {
    let ans: CellMeta[] = [];
    let selectedRowIndexSet: Set<number> = new Set<number>();
    const oldCells: CellState[] = oldState.cells;
    const columnMetas = cells[0].meta.spreadsheet?.dataSet.meta;

    if (columnMetas) {
        oldCells.forEach((oldCell) => {
            selectedRowIndexSet.add(oldCell.rowIndex);
        });

        Array.from(selectedRowIndexSet).forEach((rowIndex: number) => {
            columnMetas.forEach((column: Meta, index: number) => {
                ans.push({
                    colIndex: index,
                    rowIndex,
                    type: 'dataCell',
                    id: `${rowIndex}-root[&]${column.name}`,
                });
            });
        });
    }

    return {
        stateName: 'selected',
        force: true,
        cells: ans,
    } as InteractionStateInfo;
}

// 用于将选中的单元格扩展到行粒度(支持左键单击、按住 Crtl 或 Shift 左键单击)
export function getRowCellsByClick(oldState: State, cell: Cell, shiftKey: boolean = false): InteractionStateInfo {
    let ans: CellMeta[] = [];
    let selectedRowIndexSet: Set<number> = new Set<number>();
    const oldCells: CellState[] = oldState.cells;
    const columnMetas = cell.meta.spreadsheet?.dataSet.meta;
    const targetRowIndex = cell.meta.rowIndex;
    const targetColIndex = cell.meta.colIndex;

    const filteredCells = oldCells.filter((c) => {
        return c.rowIndex === targetRowIndex && c.colIndex !== targetColIndex;
        // return (c.rowIndex === targetRowIndex && c.colIndex !== targetColIndex) && (c.rowIndex === targetRowIndex && c.colIndex === targetColIndex);
        // return !(c.rowIndex === targetRowIndex && c.colIndex === targetColIndex);
    });

    if (columnMetas) {
        oldCells.forEach((oldCell) => {
            selectedRowIndexSet.add(oldCell.rowIndex);
        });

        if (filteredCells.length > 0 && !shiftKey) {
            selectedRowIndexSet.delete(targetRowIndex);
        } else {
            selectedRowIndexSet.add(targetRowIndex);
        }

        Array.from(selectedRowIndexSet).forEach((rowIndex: number) => {
            columnMetas.forEach((column: Meta, index: number) => {
                ans.push({
                    colIndex: index,
                    rowIndex,
                    type: 'dataCell',
                    id: `${rowIndex}-root[&]${column.name}`,
                });
            });
        });
    }

    return {
        stateName: 'selected',
        force: true,
        cells: ans,
    } as InteractionStateInfo;
}

types.d.ts

import { DataCell, S2Event, TableSheet, S2DataConfig } from "@antv/s2";

export type CellMeta = {
  colIndex: number;
  rowIndex: number;
  spreadsheet?: TableSheet;
  type?: string;
  id?: string;
};

export type Cell = {
  meta: CellMeta;
};

export type MergedCell = {
  cells: Cell[];
};

export type CellState = {
  colIndex: number;
  rowIndex: number;
  type: string;
  id: string;
};

export type State = {
  cells: CellState[];
  interactedCells?: any[];
  [key: any]: any;
};

export type CustomMergeConfig = {
  isOn: boolean;
  mergeKey?: string;
  colIndex?: number;
};

import { CustomEvent } from "./config.ts";

// 回调函数类型映射
type EventCallbackMappings = {
  [CustomEvent.MERGED_CELLS_CLICK]: (
    event: GraphEvent,
    cell: Object,
    cellRow: Object
  ) => void;
  [CustomEvent.GLOBAL_CONTEXT_MENU]: (
    event: GraphEvent,
    cell: Object,
    cellRow: Object
  ) => void;
  [CustomEvent.DATA_CELL_CLICK]: (
    event: GraphEvent,
    cell: DataCell,
    cellRow: Object
  ) => void;
  [CustomEvent.DATA_CELL_BRUSH_SELECTION]: (
    cells: (DataCell | CellMeta)[]
  ) => void;
};

// 使用泛型和条件类型定义 DefaultEventConfig
export type DefaultEventConfig<T extends CustomEvent> = {
  eventName: T;
  isOn: boolean;
  params?: { [key: string]: any };
  callback?: EventCallbackMappings[T];
};

export type EventConfig = {
  eventName: S2Event;
  isOn: boolean;
  callback: (param) => void;
};

export type ResizeConfig = {
  wrapper?: string | HTMLElement; // 可以传元素的 id, 或直接传 DOM 元素, 不指定时默认使用 container
  debounceTime?: number;
  calcResizeFunc?: (
    width: number,
    height: number
  ) => { width: number; height: number };
};

export type CustomConfig = {
  mergeConfig?: CustomMergeConfig;
  defaultEventsConfig?: DefaultEventConfig[];
  eventsConfig?: EventConfig[];
  resizeConfig?: ResizeConfig;
};

export type Config = {
  dataConfig: S2DataConfig;
  options?: S2Options;
  customConfig?: CustomConfig;
};

events.ts

import {S2Event, BaseEvent} from '@antv/s2';

export class ContextMenuInteraction extends BaseEvent {
    bindEvents() {
        this.spreadsheet.on(S2Event.GLOBAL_CONTEXT_MENU, (event) => {
            // 禁止弹出右键菜单
            event.preventDefault();
        });
    }
}

config.ts

import type {S2Theme, TextTheme, SplitLine} from '@antv/s2';
import {ContextMenuInteraction} from "@/views/overview/overview-content/cp-pane/events.ts";

const interactionState = {
    highlight: {
        backgroundColor: '#E7E1F3',
        borderColor: '#5D2DCD',
        borderWidth: 0
    },
    hover: {
        backgroundColor: '#E7E1F3',
        borderColor: '#5D2DCD',
        borderWidth: 0
    },
    hoverFocus: {
        backgroundColor: '#E7E1F3',
        borderColor: '#5D2DCD',
        borderWidth: 0
    },
    selected: {
        backgroundColor: '#EEEEFF',
        borderColor: '#5D2DCD',
        borderWidth: 0
    },
    prepareSelect: {
        backgroundColor: '#EEEEFF',
        borderColor: '#5D2DCD',
        borderWidth: 1
    }
};

export const themeOptions: S2Theme = {
    dataCell: {
        cell: {
            backgroundColor: "#fff",
            crossBackgroundColor: "#fff",
            horizontalBorderColor: "#D7D8DC",
            verticalBorderColor: "#D7D8DC",
            horizontalBorderWidth: 1,
            verticalBorderWidth: 1,
            interactionState: interactionState,
        },
        text: {
            textAlign: 'center',
            textBaseline: 'middle',
        } as TextTheme
    },
    colCell: {
        cell: {
            backgroundColor: "#E0E0E0",
            horizontalBorderColor: "#D7D8DC",
            verticalBorderColor: "#D7D8DC",
            horizontalBorderWidth: 1,
            verticalBorderWidth: 1,
            interactionState: interactionState,
        }
    },
    prepareSelectMask: {
        backgroundColor: 'rgba(93,45,205,0.3)',
        borderColor: '#5D2DCD',
        borderWidth: 2,
    },
    splitLine: {
        horizontalBorderColor: "#D7D8DC",
        horizontalBorderColorOpacity: 1,
        horizontalBorderColorWidth: 1,
        verticalBorderColor: "#D7D8DC",
        verticalBorderColorOpacity: 1,
        verticalBorderColorWidth: 1,
        showShadow: false,
    } as SplitLine,
};

export const defaultOptions = {
    width: 1024,
    height: 360,
    interaction: {
        resize: false,
        hoverHighlight: false,
        customInteractions: [
            {
                key: 'ContextMenuInteraction',
                interaction: ContextMenuInteraction,
            }
        ],
    }
};

export enum CustomEvent {
    MERGED_CELLS_CLICK = 'row-cell:click',
    GLOBAL_CONTEXT_MENU = 'global:context-menu',
    DATA_CELL_CLICK = 'data-cell:click',
    DATA_CELL_BRUSH_SELECTION = 'data-cell:brush-selection',
}

A Student on the way to full stack of Web3.