x-spreadsheet的学习、实践、填坑

3,075 阅读1分钟

x-spreadsheet(一个基于 Web(es6) canvas 构建的轻量级 Excel 开发库)的学习实践:

相关地址

源码目录图

xspeadsheet源码目录.png

几个重要的类

微信截图_20221124175827.png

Spreadsheet

主要入口类,涉及表格初始化、数据初始化等,每个方法最后return this都会返回该对象的引用,支持通过一句语句完成很多操作。

文件目录:src/index.js

class Spreadsheet {
  constructor(selectors, options = {}) {
    let targetEl = selectors;
    this.options = { showBottomBar: true, ...options };
    this.sheetIndex = 1;
    this.datas = [];
    if (typeof selectors === 'string') {
      targetEl = document.querySelector(selectors);
    }
    this.bottombar = this.options.showBottomBar ? new Bottombar(() => {
      const d = this.addSheet();
      this.sheet.resetData(d);
    }, (index) => {
      const d = this.datas[index];
      this.sheet.resetData(d);
    }, () => {
      this.deleteSheet();
    }, (index, value) => {
      this.datas[index].name = value;
    }) : null;

    this.data = this.addSheet();
    const rootEl = h('div', `${cssPrefix}`)
      .on('contextmenu', evt => evt.preventDefault());
    // create canvas element
    targetEl.appendChild(rootEl.el);
    this.sheet = new Sheet(rootEl, this.data);
    if (this.bottombar !== null) {
      rootEl.child(this.bottombar.el);
    }
  }

  addSheet(name, active = true) {} // 新建工作表
  deleteSheet() {} // 删除工作表
  loadData(data) {} // 加载数据
  getData() {} // 获取数据
  cellText(ri, ci, text, sheetIndex = 0) { // 设置单元格文字
    this.datas[sheetIndex].setCellText(ri, ci, text, 'finished');
    return this;
  }
  cell(ri, ci, sheetIndex = 0) {} // 获取该单元格
  cellStyle(ri, ci, sheetIndex = 0) {}
  reRender() { // 每次loadData后,都要调用reRender,让页面重新渲染
    this.sheet.table.render();
    return this;
  }
  on(eventName, func) {}
  validate() {}
  change(cb) {}
  static locale(lang, message) {}
}

其中初始化配置传入的options如下:

// 见src/index.d.ts
export interface Options {
  mode?: 'edit' | 'read'; // read不可编辑、edit可编辑
  showToolbar?: boolean; // 是否展示工具条
  showGrid?: boolean; // 是否显示网格
  showContextmenu?: boolean; // 是否展示右键菜单
  showBottomBar?: boolean; // 是否展示底部功能条
  view?: { // view视图的宽高配置
    height: () => number;
    width: () => number;
  };
  row?: { 
    len: number; // 限制行数
    height: number; // 行高
  };
  col?: {
    len: number; // 列数
    width: number; // 列宽
    indexWidth: number;
    minWidth: number;
  };
  style?: { // 样式
    bgcolor: string;
    align: 'left' | 'center' | 'right';
    valign: 'top' | 'middle' | 'bottom';
    textwrap: boolean;
    strike: boolean;
    underline: boolean;
    color: string;
    font: {
      name: 'Helvetica';
      size: number;
      bold: boolean;
      italic: false;
    };
  };
}

Sheet

class Sheet {
  constructor(targetEl, data) {}
  on(eventName, func) {}
  trigger(eventName, ...args) {}
  findCell(value) {}
  toCell(ri, ci) {}
  resetData(data) {}
  loadData(data) { // data是一个数组,数组中每个元素(对像)就是渲染在每个sheet的data
    const ds = Array.isArray(data) ? data : [data];
    if (this.bottombar !== null) {
      this.bottombar.clear();
    }
    if(this.sheet && this.sheet.selector){
      this.sheet.selector.hide()
    }
    this.datas = [];
    if (ds.length > 0) {
      for (let i = 0; i < ds.length; i += 1) {
        const it = ds[i];
        const nd = this.addSheet(it.name, i === 0);
        nd.setData(it);
        if (i === 0) {
          this.sheet.resetData(nd);
        }
      }
    }
    return this;
  }
  freeze(ri, ci) { // freeze rows or cols 冻结
    const { data } = this;
    data.setFreeze(ri, ci);
    sheetReset.call(this);
    return this;
  }
  undo() {}
  redo() {}
  reload() {}
  getRect() {}
  getTableOffset() {}

}

DataProxy

export default class DataProxy {
  constructor(name, settings) {
    this.settings = helper.merge(defaultSettings, settings || {});
    this.name = name || 'sheet';
    this.freeze = [0, 0];
    this.styles = [];
    this.merges = new Merges();
    this.rows = new Rows(this.settings.row);
    this.cols = new Cols(this.settings.col);
    this.validations = new Validations();
    this.hyperlinks = {};
    this.comments = {};
    this.selector = new Selector();
    this.scroll = new Scroll();
    this.history = new History(); // 历史记录,存放undo、redo撤销重做的undoItems、redoItems
    this.clipboard = new Clipboard(); // 剪切板,存放range、state:clear|copy|cut
    this.autoFilter = new AutoFilter();
    this.change = () => {};
    this.exceptRowSet = new Set();
    this.sortedRowMap = new Map();
    this.unsortedRowMap = new Map();
    this.cellChange = () => {}
  }

  // ————————校验methods————————
  addValidation(mode, ref, validator) {}

  removeValidation() {}

  getSelectedValidator() {}

  getSelectedValidation() {}

  // ————————撤销methods————————
  canUndo() {}

  canRedo() {}

  undo() {}

  redo() {}

  // ————————剪切、复制、粘贴methods————————
  copy() {}

  copyToSystemClipboard() {}

  cut() {}

  // what: all | text | format
  paste(what = 'all', error = () => { }){}

  pasteFromText(txt) {}

  clearClipboard() {}

  getClipboardRect() {}

  // ————————聚焦methods————————
  autofill(cellRange, what, error = () => {}) {}

  canAutofilter() {}

  autofilter() {}

  setAutoFilter(ci, order, operator, value) {}
  
  resetAutoFilter() {}

  // ————————选中methods————————
  calSelectedRangeByEnd(ri, ci) {}

  calSelectedRangeByStart(ri, ci) {}

  setSelectedCellAttr(property, value) {}

  // state: input | finished
  setSelectedCellText(text, state = 'input') {}

  getSelectedCell() {}

  xyInSelectedRect(x, y) {}

  getSelectedRect() {}

  isSignleSelected() {}

  // ————————rect methods————————
  getRect(cellRange) {}

  getCellRectByXY(x, y) {}

  cellRect(ri, ci) {}

  // ————————合并 methods————————
  canUnmerge() {}

  merge() {}

  unmerge() {}

  eachMergesInView(viewRange, cb) {}

  // ————————删除methods————————
  deleteCell(what = 'all') {}

  // type: row | column
  delete(type) {}

  // ————————插入methods————————
  // type: row | column
  insert(type, n = 1) {}

  // ————————滚动methods————————
  scrollx(x, cb) {}

  scrolly(y, cb) {}

  // ————————getCell methods————————
  getCell(ri, ci) {}

  getCellTextOrDefault(ri, ci) {}

  // ————————cell style methods————————
  getCellStyle(ri, ci) {}

  getCellStyleOrDefault(ri, ci) {}

  getSelectedCellStyle() {}

  defaultStyle() {}

  addStyle(nstyle) {}

  // ————————编辑methods————————
  // state: input | finished
  setCellText(ri, ci, text, state) {}

  // ————————冻结 methods————————
  freezeIsActive() {}

  setFreeze(ri, ci) {}

  freezeTotalWidth() {}

  freezeTotalHeight() {}

  freezeViewRange() {}

  // ————————设置宽高 methods————————
  setRowHeight(ri, height) {}

  setColWidth(ci, width) {}

  viewHeight() {}

  viewWidth() {}

  // ————————range methods————————
  contentRange() {}

  viewRange() {}

  // ————————data相关 methods————————
  changeData(cb) {}

  setData(d) {}

  getData() {}

  // ————————Rows Cols methods————————
  hideRowsOrCols() {}
  
  exceptRowTotalHeight(sri, eri) {}

  // type: row | col
  // index row-index | col-index
  unhideRowsOrCols(type, index) {}

  rowEach(min, max, cb) {}

  colEach(min, max, cb) {}
}

Draw

文件目录:src/canvas/draw.js 这个类Draw,用canvas上下文封装了一些常用的底层的绘制的方法,如resize、clear、save、restore、text、border等等。

class Draw {
  constructor(el, width, height) {
    this.el = el;
    this.ctx = el.getContext('2d');
    this.resize(width, height);
    this.ctx.scale(dpr(), dpr());
  }

  resize(width, height) {}

  clear() {}

  attr(options) {}

  save() {}

  restore() {}

  beginPath() {}
  
  translate(x, y) {}

  scale(x, y) {}

  clearRect(x, y, w, h) {}

  fillRect(x, y, w, h) {}

  fillText(text, x, y) {}

  text(mtxt, box, attr = {}, textWrap = true) {}

  border(style, color) {}

  line(...xys) {}

  strokeBorders(box) {}

  dropdown(box) {}

  error(box) {}

  frozen(box) {}

  rect(box, dtextcb) {}
}

实践总结

下面这段代码涉及:

  1. xspreadsheet常用事件监听:cell-selectedcells-selectedcell-edited
  2. 序号列判断 ci === -1
  3. 单元格编辑状态:input编辑中|finished编辑结束
  4. 发现的几个xspreadsheet没有处理好的问题
    • 初始化配置传styles参数,没有兼容其不传的情况
    • 单元格编辑结束,文字为空时,编辑状态state不返回finished
  5. 给xspreadsheet增加其他事件监听:通过给canvas上的div const sheetOverlayer = document.querySelector(".x-spreadsheet-overlayer")增加事件监听
const initSetting = rawData => {
  const { w, h } = getSheetSize("#selector"); // document.querySelector('#selector').getBoundingClientRect()
  const { cols, rows } = createSheetData(rawData, w); // format源数据,处理成符合xspreadsheet的数据

  const xs = window
    .x_spreadsheet("#calcSheet", {
      showToolbar: false,
      showContextmenu: false,
      showBottomBar: false,
      view: {
        height: () => h,
        width: () => w
      },
      mode
    })
    .loadData([
      {
        styles: [...styleArr], // 库兼容性不好,没有兼容styles不传的情况
        cols,
        rows
      }
    ])
    .on("cell-selected", (cell, ri, ci) => { // cell-selected 选中单元格事件
      if (cell) {
        // 内容编辑区
        if (ci > -1) {
          const { text } = cell;
          selectedCellInfo.current = buildSelectedCellInfo({
            text,
            ci,
            ri
          });
          setGuideCellInfo({
            pos: `${LETTERS[ci]}${ri + 1}`,
            val: text
          });
        } else {
          // 序号列: ci = -1
        }
      } else {}
    })
    .on("cells-selected", (cell, parameters) => { // cells-selected 多单元格选中事件
      // console.log('cells-selected:', cell, parameters);
    })
    .on("cell-edited", (cell, ri, ci, state) => { // cell-edited 单元格编辑事件,state编辑状态: input编辑中|finished编辑结束

      if (ri >= HEADER_ROWS_NUM) { // HEADER_ROWS_NUM: 列头的行数,列头不给编辑,非编辑区
        // 内容编辑区
        const cellKey = getCellKey(ci);
        const { newValue, oldValue } = cell;

        const nState =
          newValue !== oldValue && !newValue ? EEditState.finished : state; // fix:单元格在文字为空时state不返回finished的缺陷

        onEditFinish(cellKey, nState, newValue, ri, ci, oldValue); // 结束编辑
      }
    });

  // 冻结前两行
  xs.sheet.freeze(2, 0);

  sheetRef.current = xs;
  
  // 增加其他事件:给canvas上的其他dom元素增加事件监听
  const sheetOverlayer = document.querySelector(
    ".x-spreadsheet-overlayer"
  ) as HTMLDivElement;
  sheetOverlayerRef.current = sheetOverlayer;

  if (mode === EPageType.edit) {
    sheetOverlayer?.addEventListener("dblclick", onDblClick, true);
    sheetOverlayer?.addEventListener("mousedown", onMouseDown, true);
    detailWrapperRef?.current.addEventListener( // detailWrapperRef 父元素ref
      "contextmenu",
      onContextmenu,
      true
    );

    window.addEventListener("paste", onPaste, true);
    window.addEventListener("keydown", onKeydown, true);
  }
}