当前位置: 首页 > article >正文

【PPTist】表格功能

前言:这篇文章来探讨一下表格功能是怎么实现的吧!

一、插入表格

我们可以看到,鼠标移动到菜单项上出现的提示语是“插入表格”
在这里插入图片描述
那么就全局搜索一下,就发现这个菜单在 src/views/Editor/CanvasTool/index.vue 文件中

<Popover trigger="click" v-model:value="tableGeneratorVisible" :offset="10">
  <template #content>
    <TableGenerator
      @close="tableGeneratorVisible = false"
      @insert="({ row, col }) => { createTableElement(row, col); tableGeneratorVisible = false }"
    />
  </template>
  <IconInsertTable class="handler-item" v-tooltip="'插入表格'" />
</Popover>

看一下组件 TableGenerator,是用来选择表格的长宽的组件。

src/views/Editor/CanvasTool/TableGenerator.vue

<table 
  @mouseleave="endCell = []" 
  @click="handleClickTable()" 
  v-if="!isCustom"
>
  <tbody>
    <tr v-for="row in 10" :key="row">
      <td 
        @mouseenter="endCell = [row, col]"
        v-for="col in 10" :key="col"
      >
        <div 
          class="cell" 
          :class="{ 'active': endCell.length && row <= endCell[0] && col <= endCell[1] }"
        ></div>
      </td>
    </tr>
  </tbody>
</table>

可以看到主要是通过监听鼠标移入事件和鼠标离开时间。鼠标移入的时候,将鼠标移入的当前的 td 的位置赋值给 endCell,并且高亮在endCell 范围内的 td
点击的时候,创建表格元素并且插入。创建元素的方法在下面的文件中统一管理
关于表格的位置的处理还比较简单,统一放在水平垂直居中的位置。
src/hooks/useCreateElement.ts

/**
 * 创建表格元素
 * @param row 行数
 * @param col 列数
 */
const createTableElement = (row: number, col: number) => {
  const style: TableCellStyle = {
    fontname: theme.value.fontName,
    color: theme.value.fontColor,
  }
  // 创建表格数据 空的二维数组
  const data: TableCell[][] = []
  for (let i = 0; i < row; i++) {
    const rowCells: TableCell[] = []
    for (let j = 0; j < col; j++) {
      rowCells.push({ id: nanoid(10), colspan: 1, rowspan: 1, text: '', style })
    }
    data.push(rowCells)
  }

  const DEFAULT_CELL_WIDTH = 100
  const DEFAULT_CELL_HEIGHT = 36

  // 创建列宽数组 每个元素的值为1/col
  const colWidths: number[] = new Array(col).fill(1 / col)

  const width = col * DEFAULT_CELL_WIDTH
  const height = row * DEFAULT_CELL_HEIGHT

  // 创建表格元素
  createElement({
    type: 'table',
    id: nanoid(10),
    width,
    height,
    colWidths,
    rotate: 0,
    data,
    left: (VIEWPORT_SIZE - width) / 2,
    top: (VIEWPORT_SIZE * viewportRatio.value - height) / 2,
    outline: {
      width: 2,
      style: 'solid',
      color: '#eeece1',
    },
    theme: {
      color: theme.value.themeColor,
      rowHeader: true,
      rowFooter: false,
      colHeader: false,
      colFooter: false,
    },
    cellMinHeight: 36,
  })
}

以及来看一下公用的 createElement 方法都做了什么

// 创建(插入)一个元素并将其设置为被选中元素
const createElement = (element: PPTElement, callback?: () => void) => {
  // 添加元素到元素列表
  slidesStore.addElement(element)
  // 设置被选中元素列表
  mainStore.setActiveElementIdList([element.id])

  if (creatingElement.value) mainStore.setCreatingElement(null)

  setTimeout(() => {
    // 设置编辑器区域为聚焦状态
    mainStore.setEditorareaFocus(true)
  }, 0)

  if (callback) callback()

  // 添加历史快照
  addHistorySnapshot()
}

以及添加元素的方法 slidesStore.addElement
src/store/slides.ts

addElement(element: PPTElement | PPTElement[]) {
  const elements = Array.isArray(element) ? element : [element]
  const currentSlideEls = this.slides[this.slideIndex].elements
  const newEls = [...currentSlideEls, ...elements]
  this.slides[this.slideIndex].elements = newEls
},

新添加的元素就放在当前的幻灯片的元素列表的最后就行,也不用考虑按顺序摆放,因为元素里面都有各自的位置信息

mainStore.setCreatingElement() 这个方法就是设置一个公用的对象 creatingElement,设置为 null 表示创建结束啦
src/store/main.ts

setCreatingElement(element: CreatingElement | null) {
  this.creatingElement = element
},

mainStore.setEditorareaFocus(true) 聚焦于编辑区域,这个方法也简单
src/store/main.ts

setEditorareaFocus(isFocus: boolean) {
  this.editorAreaFocus = isFocus
},

还有两个方法是以前见过的

mainStore.setActiveElementIdList() 方法见 【PPTist】网格线、对齐线、标尺
addHistorySnapshot() 方法见 【PPTist】历史记录功能
总结来说, createElement 里面都干了这些事情

  • 添加元素到当前幻灯片的元素列表
  • 将这个新的元素设置为被选中的状态
  • creatingElement 置空
  • 将焦点放在编辑区域
  • 执行回调函数(如果有的话)
  • 将创建元素的行为添加到历史快照中

ok,这是表格的创建阶段完成了。

二、表格编辑

接下来要看一下表格右键的一些方法
进入表格的编辑状态,右键出来的菜单长这样
在这里插入图片描述
这个创建出来的表格的组件是 src/views/components/element/TableElement/EditableTable.vue
表格的数据是 tableCells 二维数组。这个文件里的代码有点复杂了。一个一个来吧。

1、右键菜单

菜单由指令 v-contextmenu 添加,这是一个自定义指令,定义在 src/plugins/directive/contextmenu.ts

① 自定义指令

自定义指令定义了两个生命周期函数,一个是 mounted,一个是 unmounted。自定义指令被挂载的时候,会接受一个参数。mounted 的第一个参数是默认参数,表示使用自定义指令的元素,第二个参数是通过自定义指定传递过来的参数。
然后绑定了右键菜单事件 contextmenu,并且将事件记录了一个索引值,便于元素卸载的时候解绑右键菜单时间

// 定义自定义指令
const ContextmenuDirective: Directive = {
  // 在元素挂载时
  mounted(el: CustomHTMLElement, binding) {
    // 保存事件处理器引用,方便后续解绑
    el[CTX_CONTEXTMENU_HANDLER] = (event: MouseEvent) => contextmenuListener(el, event, binding)
    // 绑定右键菜单事件
    el.addEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
  },

  // 在元素卸载时
  unmounted(el: CustomHTMLElement) {
    // 清理事件监听,避免内存泄漏
    if (el && el[CTX_CONTEXTMENU_HANDLER]) {
      el.removeEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
      delete el[CTX_CONTEXTMENU_HANDLER]
    }
  },
}
② 创建右键菜单
// 核心的右键菜单处理函数
const contextmenuListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => {
  // 阻止默认右键菜单和事件冒泡
  event.stopPropagation()
  event.preventDefault()

  // 调用指令绑定的值函数,获取菜单配置
  const menus = binding.value(el)
  if (!menus) return

  let container: HTMLDivElement | null = null

  // 清理函数:移除右键菜单并清理相关事件监听
  const removeContextmenu = () => {
    if (container) {
      document.body.removeChild(container)
      container = null
    }
    // 移除目标元素的激活状态样式
    el.classList.remove('contextmenu-active')
    // 清理全局事件监听
    document.body.removeEventListener('scroll', removeContextmenu)
    window.removeEventListener('resize', removeContextmenu)
  }

  // 准备创建菜单所需的配置项
  const options = {
    axis: { x: event.x, y: event.y },  // 鼠标点击位置
    el,                                // 目标元素
    menus,                            // 菜单配置
    removeContextmenu,                // 清理函数
  }

  // 创建容器并渲染菜单组件
  container = document.createElement('div')
  const vm = createVNode(ContextmenuComponent, options, null)
  render(vm, container)
  document.body.appendChild(container)

  // 为目标元素添加激活状态样式
  el.classList.add('contextmenu-active')

  // 监听可能导致菜单需要关闭的全局事件
  document.body.addEventListener('scroll', removeContextmenu)
  window.addEventListener('resize', removeContextmenu)
}

其中的 removeContextmenu 是一个闭包,在闭包内销毁指令创建出来的元素,并且清除自身的监听回调。
菜单配置是通过自定义指令传递过来的方法获取的。
例如表格 v-contextmenu="(el: HTMLElement) => contextmenus(el)",返回的是菜单项的数组。

const contextmenus = (el: HTMLElement): ContextmenuItem[] => {
  // 获取单元格索引
  const cellIndex = el.dataset.cellIndex as string
  const rowIndex = +cellIndex.split('_')[0]
  const colIndex = +cellIndex.split('_')[1]

  // 如果当前单元格未被选中,则将当前单元格设置为选中状态
  if (!selectedCells.value.includes(`${rowIndex}_${colIndex}`)) {
    startCell.value = [rowIndex, colIndex]
    endCell.value = []
  }

  const { canMerge, canSplit } = checkCanMergeOrSplit(rowIndex, colIndex)
  const { canDeleteRow, canDeleteCol } = checkCanDeleteRowOrCol()

  return [
    {
      text: '插入列',
      children: [
        { text: '到左侧', handler: () => insertCol(colIndex) },
        { text: '到右侧', handler: () => insertCol(colIndex + 1) },
      ],
    },
    {
      text: '插入行',
      children: [
        { text: '到上方', handler: () => insertRow(rowIndex) },
        { text: '到下方', handler: () => insertRow(rowIndex + 1) },
      ],
    },
    {
      text: '删除列',
      disable: !canDeleteCol,
      handler: () => deleteCol(colIndex),
    },
    {
      text: '删除行',
      disable: !canDeleteRow,
      handler: () => deleteRow(rowIndex),
    },
    { divider: true },
    {
      text: '合并单元格',
      disable: !canMerge,
      handler: mergeCells,
    },
    {
      text: '取消合并单元格',
      disable: !canSplit,
      handler: () => splitCells(rowIndex, colIndex),
    },
    { divider: true },
    {
      text: '选中当前列',
      handler: () => selectCol(colIndex),
    },
    {
      text: '选中当前行',
      handler: () => selectRow(rowIndex),
    },
    {
      text: '选中全部单元格',
      handler: selectAll,
    },
  ]
}

创建组件使用的是 createVNode 方法,ContextmenuComponentsrc/components/Contextmenu/index.vue 组件
createVNode 方法参数列表:

  1. type
    类型: string | object
    描述: VNode 的类型,可以是一个 HTML 标签名(如 ‘div’、‘span’ 等),也可以是一个组件的定义(如一个 Vue 组件的对象或异步组件的工厂函数)。
  2. props
    类型: object | null
    描述: 传递给组件或元素的属性。对于组件,这些属性会被作为 props 传递;对于 DOM 元素,这些属性会被直接应用到元素上。
  3. children
    类型: string | VNode | Array<VNode | string> | null
    描述: VNode 的子节点,可以是一个字符串(文本节点)、一个 VNode、一个 VNode 数组,或者是 null。如果提供了多个子节点,可以用数组的形式传递。

通过createVNode 方法,会将鼠标点击的位置、目标元素、菜单配置以及清理函数传递给自定义指令的组件。

并且给全局增加了滚动事件的监听和调整大小事件的监听,当滚动鼠标或者调整页面大小的时候,就隐藏右键菜单。

③ 右键菜单组件

右键菜单组件是 src/components/Contextmenu/index.vue,其中的菜单项是 src/components/Contextmenu/MenuContent.vue
菜单里面的具体的菜单项上面已经讲过是咋来的,使用自定义指令的时候,通过方法返回一个对象数组。点击菜单项的时候,执行回调函数

const handleClickMenuItem = (item: ContextmenuItem) => {
  if (item.disable) return
  if (item.children && !item.handler) return
  if (item.handler) item.handler(props.el)
  props.removeContextmenu()
}

2、插入列

// 插入一列
const insertCol = (colIndex: number) => {
  tableCells.value = tableCells.value.map(item => {
  	// 每一行都要在 colIndex 的地方添加一个元素
    const cell = {
      colspan: 1,
      rowspan: 1,
      text: '',
      id: nanoid(10),
    }
    item.splice(colIndex, 0, cell)
    return item 
  })
  colSizeList.value.splice(colIndex, 0, 100)
  emit('changeColWidths', colSizeList.value)
}

在模版中,表格项遍历的时候,会给每一个 td 元素添加一个属性 :data-cell-index="KaTeX parse error: Expected group after '_' at position 11: {rowIndex}_̲{colIndex}"
插入列的时候,如果是向左插入,colIndex 直接取元素上绑定的值,如果是向右插入,需要取 colIndex + 1
输出一下 colSizeList.value,它记录的是所有列的宽度,所以这里插入的是 100,即默认插入列的宽度是 100px
在这里插入图片描述

3、插入行

行的数据就复杂那么一丢丢

// 插入一行
const insertRow = (rowIndex: number) => {
  const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))

  const rowCells: TableCell[] = []
  for (let i = 0; i < _tableCells[0].length; i++) {
    rowCells.push({
      colspan: 1,
      rowspan: 1,
      text: '',
      id: nanoid(10),
    })
  }

  _tableCells.splice(rowIndex, 0, rowCells)
  tableCells.value = _tableCells
}

插入的时候需要创建一个数组

我们看一下里面的几个数据分别长什么样子
如图下面这个表格,我在第一行的下面增加一行的时候
在这里插入图片描述
新的一行的数据如下:
在这里插入图片描述

_tableCells 的数据如下:
是一个二维数组
在这里插入图片描述

在模版中,表格是遍历二维数组 tableCells 创建的。至于单元格的宽度,是通过 colgroup标签,循环 colSizeList 制定的。

<colgroup>
  <col span="1" v-for="(width, index) in colSizeList" :key="index" :width="width">
</colgroup>

这个标签主要用来指定列的宽度。span 属性我看官网说已经禁用了。

删除行或列类似,主要通过 splice 方法进行数组元素的剪切

4、合并单元格

这是比较复杂的功能了。它会修改最小的坐标处的单元格的 colSpanrowspan ,表示当前这个单元格占多少单位行或者单位列。但是后面的单元格,不会删除,会隐藏掉。也就是说,二维数组的结构不变,只是其中的合并单元格的开头单元格的 rowSpancolSpan 变了
下面这个单元格,就是被合并的效果,第一个单元格撑宽,第二个单元格 display: none
在这里插入图片描述
看一下被合并的单元格的数据,只有第一个格格的数据会被修改
在这里插入图片描述

// 合并单元格
const mergeCells = () => {
  const [startX, startY] = startCell.value
  const [endX, endY] = endCell.value

  const minX = Math.min(startX, endX)
  const minY = Math.min(startY, endY)
  const maxX = Math.max(startX, endX)
  const maxY = Math.max(startY, endY)

  const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  
  // 更新坐标最小的单元格的rowspan和colspan
  _tableCells[minX][minY].rowspan = maxX - minX + 1
  _tableCells[minX][minY].colspan = maxY - minY + 1

  tableCells.value = _tableCells
  removeSelectedCells()
}

起始的单元格 startCell 在鼠标落下的时候会更新它的值

const handleCellMousedown = (e: MouseEvent, rowIndex: number, colIndex: number) => {
  if (e.button === 0) {
    endCell.value = []
    isStartSelect.value = true
    startCell.value = [rowIndex, colIndex]
  }
}

结束的单元格在鼠标移入单元格的时候就会更新

const handleCellMouseenter = (rowIndex: number, colIndex: number) => {
  if (!isStartSelect.value) return
  endCell.value = [rowIndex, colIndex]
}

隐藏后面的单元格是怎么实现的呢?是通过 td 标签上的 v-show="!hideCells.includes(KaTeX parse error: Expected group after '_' at position 11: {rowIndex}_̲{colIndex})" 这个判断实现的。
hideCells 的计算在 src/views/components/element/TableElement/useHideCells.ts 文件中,它是个计算属性,但是竟然也分成一个文件写,所以这代码管理的层级很好哦

// 这是一个组合式函数 (Composable),用于处理表格合并时的单元格隐藏逻辑
export default (cells: Ref<TableCell[][]>) => {
  // computed 会创建一个响应式的计算属性
  const hideCells = computed(() => {
    const hideCells: string[] = []
    
    // 双重循环遍历表格的每一个单元格
    for (let i = 0; i < cells.value.length; i++) {      // 遍历行
      const rowCells = cells.value[i]
      for (let j = 0; j < rowCells.length; j++) {       // 遍历列
        const cell = rowCells[j]
        
        // 如果当前单元格设置了合并
        if (cell.colspan > 1 || cell.rowspan > 1) {
          // 遍历被合并的区域
          for (let row = i; row < i + cell.rowspan; row++) {
            for (let col = row === i ? j + 1 : j; col < j + cell.colspan; col++) {
              // 将被合并的单元格位置添加到数组
              // 例如:如果是第2行第3列的单元格,会生成 "2_3"
              hideCells.push(`${row}_${col}`)
            }
          }
        }
      }
    }
    return hideCells
  })

  return {
    hideCells, // 返回需要隐藏的单元格位置数组
  }
}

5、拆分单元格

这个方法就挺简单的了,之前我们合并单元格的时候,是把坐标最小的单元格的 rowSpancolSpan 修改成合并单元格选中的横向格数和纵向格数。那么拆分单元格,直接把单元格的 rowSpancolSpan 都变回 1 就可以了。

// 拆分单元格
const splitCells = (rowIndex: number, colIndex: number) => {
  const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  _tableCells[rowIndex][colIndex].rowspan = 1
  _tableCells[rowIndex][colIndex].colspan = 1

  tableCells.values
  removeSelectedCells()
}

修改表格数据的方法,基本上都使用的是 tableCells.value 重新给表格数据赋值的方法。这也就确保上面的计算属性 hideCells 能触发更新。

6、选中当前列/行、选中全部单元格

选中这个操作,处理起来很简单,只是修改两个表示范围的响应式数据 startCellendCell

// 选中指定的列
const selectCol = (index: number) => {
  const maxRow = tableCells.value.length - 1
  startCell.value = [0, index]
  endCell.value = [maxRow, index]
}

另外两个也类似,就不粘贴了。
然后选中的单元格会有高亮效果,在模版中 td 标签上

:class="{
         'selected': selectedCells.includes(`${rowIndex}_${colIndex}`) && selectedCells.length > 1,
         'active': activedCell === `${rowIndex}_${colIndex}`,
       }"

选中的单元格是计算属性 selectedCells

// 当前选中的单元格集合
const selectedCells = computed(() => {
  if (!startCell.value.length) return []
  const [startX, startY] = startCell.value

  if (!endCell.value.length) return [`${startX}_${startY}`]
  const [endX, endY] = endCell.value

  if (startX === endX && startY === endY) return [`${startX}_${startY}`]

  const selectedCells = []

  const minX = Math.min(startX, endX)
  const minY = Math.min(startY, endY)
  const maxX = Math.max(startX, endX)
  const maxY = Math.max(startY, endY)

  for (let i = 0; i < tableCells.value.length; i++) {
    const rowCells = tableCells.value[i]
    for (let j = 0; j < rowCells.length; j++) {
      if (i >= minX && i <= maxX && j >= minY && j <= maxY) selectedCells.push(`${i}_${j}`)
    }
  }
  return selectedCells
})


然后捏,选中的单元格修改的时候,还需要触发一个自定义函数

watch(selectedCells, (value, oldValue) => {
  if (isEqual(value, oldValue)) return
  emit('changeSelectedCells', selectedCells.value)
})

在父组件中监听这个函数,更新全局的 selectedTableCells 属性

// 更新表格当前选中的单元格
const updateSelectedCells = (cells: string[]) => {
  nextTick(() => mainStore.setSelectedTableCells(cells))
}

7、删除列/行

删除列/行的代码差不多,都是使用 splice 方法,将删除的单元格截取掉。

// 删除一行
const deleteRow = (rowIndex: number) => {
  const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))

  const targetCells = tableCells.value[rowIndex]
  const hideCellsPos = []
  for (let i = 0; i < targetCells.length; i++) {
    if (isHideCell(rowIndex, i)) hideCellsPos.push(i)
  }
  
  for (const pos of hideCellsPos) {
    for (let i = rowIndex; i >= 0; i--) {
      if (!isHideCell(i, pos)) {
        _tableCells[i][pos].rowspan = _tableCells[i][pos].rowspan - 1
        break
      }
    }
  }

  _tableCells.splice(rowIndex, 1)
  tableCells.value = _tableCells
}
// 删除一列
const deleteCol = (colIndex: number) => {
  const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))

  const hideCellsPos = []
  for (let i = 0; i < tableCells.value.length; i++) {
    if (isHideCell(i, colIndex)) hideCellsPos.push(i)
  }

  for (const pos of hideCellsPos) {
    for (let i = colIndex; i >= 0; i--) {
      if (!isHideCell(pos, i)) {
        _tableCells[pos][i].colspan = _tableCells[pos][i].colspan - 1
        break
      }
    }
  }

  tableCells.value = _tableCells.map(item => {
    item.splice(colIndex, 1)
    return item
  })
  colSizeList.value.splice(colIndex, 1)
  emit('changeColWidths', colSizeList.value)
}

8、快捷键

快捷键是上下左右箭头,以及 ctrl + 上下左右箭头。代码看起来还是比较好理解的

// 表格快捷键监听
const keydownListener = (e: KeyboardEvent) => {
  if (!props.editable || !selectedCells.value.length) return

  const key = e.key.toUpperCase()
  if (selectedCells.value.length < 2) {
    if (key === KEYS.TAB) {
      e.preventDefault()
      tabActiveCell()
    }
    else if (e.ctrlKey && key === KEYS.UP) {
      e.preventDefault()
      const rowIndex = +selectedCells.value[0].split('_')[0]
      insertRow(rowIndex)
    }
    else if (e.ctrlKey && key === KEYS.DOWN) {
      e.preventDefault()
      const rowIndex = +selectedCells.value[0].split('_')[0]
      insertRow(rowIndex + 1)
    }
    else if (e.ctrlKey && key === KEYS.LEFT) {
      e.preventDefault()
      const colIndex = +selectedCells.value[0].split('_')[1]
      insertCol(colIndex)
    }
    else if (e.ctrlKey && key === KEYS.RIGHT) {
      e.preventDefault()
      const colIndex = +selectedCells.value[0].split('_')[1]
      insertCol(colIndex + 1)
    }
    else if (key === KEYS.UP) {
      const range = getCaretPosition(e.target as HTMLDivElement)
      if (range && range.start === range.end && range.start === 0) {
        moveActiveCell('UP')
      }
    }
    else if (key === KEYS.DOWN) {
      const range = getCaretPosition(e.target as HTMLDivElement)
      if (range && range.start === range.end && range.start === range.len) {
        moveActiveCell('DOWN')
      }
    }
    else if (key === KEYS.LEFT) {
      const range = getCaretPosition(e.target as HTMLDivElement)
      if (range && range.start === range.end && range.start === 0) {
        moveActiveCell('LEFT')
      }
    }
    else if (key === KEYS.RIGHT) {
      const range = getCaretPosition(e.target as HTMLDivElement)
      if (range && range.start === range.end && range.start === range.len) {
        moveActiveCell('RIGHT')
      }
    }
  }
  else if (key === KEYS.DELETE) {
    clearSelectedCellText()
  }
}

关于 moveActiveCell() 方法,里面的主要做的事情,就是调整 startCell ,起始单元格的位置。

9、快捷键bug

然后这里发现了一个小bug。我使用的是搜狗输入法。如果我正在输入中文,然后点击了上下左右箭头,想选择输入法中的目标文字,焦点就会直接跳转到目标单元格,编辑器的快捷键覆盖了输入法的快捷键。所以应该判断一下,如果当前正在编辑,就不进行单元格的跳转了。
使用 KeyboardEvent.isComposing 事件的 isComposing 属性判断是否在进行输入法输入即可。

const keydownListener = (e: KeyboardEvent) => {
  if (!props.editable || !selectedCells.value.length) return
  // 添加输入法检查
  if (e.isComposing) return
  const key = e.key.toUpperCase()
  // ... 
}

表格功能确实是很复杂啊,细节太多了。


http://www.kler.cn/a/456279.html

相关文章:

  • Bert中文文本分类
  • RustDesk内置ID服务器,Key教程
  • 嵌入式硬件杂谈(七)IGBT MOS管 三极管应用场景与区别
  • FreeType矢量字符库的介绍、交叉编译以及安装
  • [创业之路-204]:《华为战略管理法-DSTE实战体系》- 5-平衡记分卡绩效管理
  • 使用vue3搭建前端模拟增删改查
  • PyTorch框架——基于深度学习LYT-Net神经网络AI低光图像增强系统源码
  • 2.5.3 文件使用、共享、保护、安全与可靠性
  • 瑞芯微全新芯片平台RK3506优势详解,高集成低功耗,为工业而生 触觉智能测评
  • Proteus仿真——《51单片机AD和DA转换器的设计》
  • 【数据结构】单链表的使用
  • 【CSS in Depth 2 精译_096】16.4:CSS 中的三维变换 + 16.5:本章小结
  • 解决VSCODE输出python中文乱码问题
  • 【网络云计算】2024第52周-每日【2024/12/26】小测-理论实操-备份MySQL数据库并发送邮件-解析
  • 【从0带做】基于Springboot3+Vue3的高校食堂点餐系统
  • C# 编程系列:网络通信之TCP通信(第一篇:介绍TCP协议在C#中的基本概念和工作原理)
  • wordpres当前分类调用父分类的名称和链接
  • Vue3响应式:Proxy设计原理解析
  • 在 Linux 中如何使用粘滞位 (t-bit)共享文件
  • 基于websocket实现本地web语音聊天
  • 每日一题 347. 前 K 个高频元素
  • 数据库原理及应用(MySQL版-李月军)-习题参考答案
  • 【RabbitMQ】超详细Windows系统下RabbitMQ的安装配置
  • 如何使用fetch函数获取多个数据并同时使用(在嵌套的fetch函数之间传递数据)
  • 如何为运行在 PICO 4 Ultra 设备上的项目设置外部文件读写权限?
  • pdf有密码,如何实现pdf转换word?