手把手教你实现一个可编辑的Table

avatar
前端工程师 @豌豆公主

手把手教你实现一个可编辑的Table

需求背景

  • 我们最近在做一些商品的备案工作,历史有很多的备案记录,不能很好的利用。所以想做一个提效工具。

  • 备案人员,在网页通过搜索历史知识库的数据,进行备案编辑,他们平时都在使用Excel,所以要求样式和使用体验求尽量向Excel 靠近,当然本篇主要讲table在线编辑的一些实现思路

主要实现功能

  • 在线编辑
  • 支持键盘导航
  • 支持批量复制粘贴

效果

editF.gif

具体实现细节

表格搭建

  • html 采用了 table,thead, tbody,tr,td,th 数据结构采用了二维数组

template

 <table>
    <thead>
      <tr>
        <th v-for="(th, i) in headers" :key="i" @click="go">{{ th }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, rowIndex) in rowsList" :key="rowIndex">
        <td v-for="(cellValue, columnIndex) in row" :key="columnIndex">
          <div>{{ cellValue }}</div>
        </td>
      </tr>
    </tbody>
  </table>

数据结构

const headers = ref(["Name", "Age", "Gender"]);
const rowsList = ref([
  ["Alice", 20, "Female"],
  ["Bob", 30, "Male"]
]);

table可编辑

  1. api选择

可编辑提供了一些方式,input,textarea,还有h5新增的contentEditable。最终由于是多行文本编辑,并且要提供给用户一些换行等功能,所以最后选择了contentEditable

  1. 交互

交互想了两种,一种是提供一个按钮,用户点击按钮,当前行变为可编辑。

按钮编辑.gif

另一种是直接可以输入

按钮.gif

但是在我这个场景下,主要就是操作便捷,按钮无疑是增加了操作的复杂度。可是使用直接输入,这么多输入框,都是可编辑状态,性能堪忧啊。但是最后采用了直接输入,并且动态设置可编辑,只有鼠标聚焦的时候,才是可编辑

实现逻辑

  • 给每一个单元格设置一个ref,(方便做聚焦)
  • 通过点击事件,获取当前单元格的index,做contenteditable的动态编辑
  • 给可编辑的单元格做聚焦

template

++
:contenteditable="currentRow === rowIndex && currentCol === columnIndex"
:ref="setRef(rowIndex, columnIndex)"
@click="focusCell(rowIndex, columnIndex, $event)"
++

steup

++
// 获取每个单元格的ref
const setRef = (rowIndex, columnIndex) => {
  return (el) => {
    if (!refs[rowIndex]) {
      refs[rowIndex] = {};
    }
    refs[rowIndex][columnIndex] = el;
    console.log("el", el);
  };
};
const selectedRange = reactive({ start: null, end: null });
// 单击事件,获取焦点并且设置index
const focusCell = (rowIndex, columnIndex, $event) => {
    selectedRange.start = { rowIndex, columnIndex };
    selectedRange.end = { rowIndex, columnIndex };
    currentRow.value = rowIndex;
    currentCol.value = columnIndex;
  setTimeout(() => {
    refs[rowIndex][columnIndex].focus();
  });
}; 
++

🌟 到这里,可编辑就做完了,但是编辑会有一个问题,contenteditable 输入的是元素本身的值,并没有和vue 做响应式数据,那赋值就可以了

template

++
@blur="setCell(rowIndex, columnIndex, $event.target.textContent)"
++

steup

++
// 失焦后,设置数据,这样的好处是减少频繁的数据更新,不然contenteditable会出现光标不准确的bug
const setCell = (rowIndex, columnIndex, value) => {
  rowsList.value[rowIndex][columnIndex] = value;
};
++

table支持键盘导航

⭐️ 功能描述

可以通过键盘的上下左右键盘,做单元的切换

(其实就是根据键盘事件,获取当前的索引,做可编辑可focus)

template

++
@keydown="handleKeydown(rowIndex, columnIndex, $event)"
++

steup

++
const handleKeydown = (rowIndex, columnIndex, event) => {
  const { key } = event;
  // 双击之后,锁住上下左右键的切换
  if (keyFlag.value) {
    return;
  }
  console.log("event", event);
  switch (key) {
    case "ArrowUp":
      if (rowIndex > 0) {
        keyDownFunc(rowIndex - 1, columnIndex);
      }
      break;
    case "ArrowDown":
      if (rowIndex < rowsList.value.length - 1) {
        keyDownFunc(rowIndex + 1, columnIndex);
      }
      break;
    case "ArrowLeft":
      if (columnIndex > 0) {
        keyDownFunc(rowIndex, columnIndex - 1);
      }
      break;
    case "ArrowRight":
      if (columnIndex < headers.value.length - 1) {
        keyDownFunc(rowIndex, columnIndex + 1);
      }
      break;
  }
};
// 上下左右键,去设置index,并且使当前单元格聚焦
const keyDownFunc = (rowIndex, columnIndex) => {
  currentRow.value = rowIndex;
  currentCol.value = columnIndex;
  setTimeout(() => {
    refs[rowIndex][columnIndex].focus();
    console.log("refs[rowIndex][columnIndex]", refs[rowIndex][columnIndex]);
    const el = refs[rowIndex][columnIndex];
    // 此方式只针对于 input ,textarea
    // const len = refs[rowIndex][columnIndex].innerText.length

    // refs[rowIndex][columnIndex].setSelectionRange(0, len); // 设置光标到最后
    const range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  });
};
++

但是会出现一个问题,就是永远只会上线左右,无法仅在当前单元格编辑。所以设计了双击解除编辑状态

template

++
@dblclick="handleDoubleClick"
++

steup

++
const handleDoubleClick = () => {
  keyFlag.value = true;

  selectedRange.start = null;
  selectedRange.end = null;
};
++

table支持批量复制粘贴

⭐️ 功能描述

CV 复制粘贴

这个有点东西,你得先体验一下Excel里面的复制粘贴 比如我先复制一个Excel

image.png

粘贴出来

image.png 你会发现他是带格式的。

但是我们自己实现的table,复制下来没格式,这个时候,我们就需要自己去处理换行和空格

  1. 复制

    template

++
@copy="handleCopy($event)"
++

steup

++
const focusCell = (rowIndex, columnIndex, $event) => {
  console.log("$event", $event);
  if ($event.shiftKey) { // shift
    selectedRange.end = { rowIndex, columnIndex };
  }

function handleCopy($event) {
  currentRow.value = null;
   // 自己处理格式,空格\t ,换行\n
  const selectedValues = getSelectedValues();
  if (selectedValues) {
    ElMessage({
      message: "复制成功",
      type: "success"
    });
  }

  $event.clipboardData.setData("text", selectedValues);
  $event.preventDefault();
}
function getSelectedValues() {
  const { rowIndex: startRow, columnIndex: startCol } = selectedRange.start;
  const { rowIndex: endRow, columnIndex: endCol } = selectedRange.end;
  let rowData = "";
  for (let i = startRow; i <= endRow; i++) {
    for (let j = startCol; j <= endCol; j++) {
      rowData += rowsList.value[i][j] + "\t";
    }
    rowData += "\n";
  }

  return rowData;
}
++

  1. 粘贴

    template

++
@paste="handlePaste($event)"
++

steup

++
function handlePaste($event) {
  const clipboardData = $event.clipboardData;
  if (clipboardData) {
    ElMessage({
      message: "粘贴成功",
      type: "success"
    });
  }
  const pastedData = clipboardData.getData("text");

  setSelectedValues(pastedData);
  $event.preventDefault();
}
function setSelectedValues(pastedData) {
  console.log("pastedData", pastedData);
  // 如果复制数据的大小与所选单元格不匹配,则退出
  const { rowIndex: startRow, columnIndex: startCol } = selectedRange.start;
  const { rowIndex: endRow, columnIndex: endCol } = selectedRange.end;
  const rows = pastedData
    .trim()
    .split("\n")
    .map((row) => row.split("\t"));
  for (let i = 0; i < rows.length && startRow + i <= endRow; i++) {
    const row = rows[i];
    for (let j = 0; j < row.length && startCol + j <= endCol; j++) {
      rowsList.value[startRow + i][startCol + j] = row[j];
    }
  }
}
++

  1. shift 复制粘贴的样式

就是一个函数,根据单元格的范围,是就return true。否就返回false

++
:class="{
         selected: isSelected(rowIndex,columnIndex)
       }"
++

steup

++
const isSelected = (rowIndex, columnIndex) => {
  const { start, end } = selectedRange;

  if (!start || !end) return false;
  if (
    rowIndex >= start.rowIndex &&
    rowIndex <= end.rowIndex &&
    columnIndex >= start.columnIndex &&
    columnIndex <= end.columnIndex
  ) {
    return true;
  }
  if (
    rowIndex >= end.rowIndex &&
    rowIndex <= start.rowIndex &&
    columnIndex >= end.columnIndex &&
    columnIndex <= start.columnIndex
  ) {
    return true;
  }

  return false;
};
++

市面上也有很多其他的可编辑table 比如ant procomponents.ant.design/components/…

vxe vxetable.cn/#/table/edi…

但是都不满足需求,所以自己造了一个轮子。 下面是github地址,欢迎start

gitee.com/Big_Cat-AK-…