【PPTist】表格功能

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

一、插入表格

我们可以看到,鼠标移动到菜单项上出现的提示语是"插入表格"

那么就全局搜索一下,就发现这个菜单在 src/views/Editor/CanvasTool/index.vue 文件中

html 复制代码
<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

typescript 复制代码
<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

typescript 复制代码
/**
 * 创建表格元素
 * @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 方法都做了什么

typescript 复制代码
// 创建(插入)一个元素并将其设置为被选中元素
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

typescript 复制代码
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

typescript 复制代码
setCreatingElement(element: CreatingElement | null) {
  this.creatingElement = element
},

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

typescript 复制代码
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,并且将事件记录了一个索引值,便于元素卸载的时候解绑右键菜单时间

typescript 复制代码
// 定义自定义指令
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]
    }
  },
}
② 创建右键菜单
typescript 复制代码
// 核心的右键菜单处理函数
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)",返回的是菜单项的数组。

typescript 复制代码
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

菜单里面的具体的菜单项上面已经讲过是咋来的,使用自定义指令的时候,通过方法返回一个对象数组。点击菜单项的时候,执行回调函数

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

2、插入列

typescript 复制代码
// 插入一列
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、插入行

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

typescript 复制代码
// 插入一行
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 制定的。

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

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

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

4、合并单元格

这是比较复杂的功能了。它会修改最小的坐标处的单元格的 colSpanrowspan ,表示当前这个单元格占多少单位行或者单位列。但是后面的单元格,不会删除,会隐藏掉。也就是说,二维数组的结构不变,只是其中的合并单元格的开头单元格的 rowSpancolSpan 变了

下面这个单元格,就是被合并的效果,第一个单元格撑宽,第二个单元格 display: none

看一下被合并的单元格的数据,只有第一个格格的数据会被修改

typescript 复制代码
// 合并单元格
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 在鼠标落下的时候会更新它的值

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

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

typescript 复制代码
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 文件中,它是个计算属性,但是竟然也分成一个文件写,所以这代码管理的层级很好哦

typescript 复制代码
// 这是一个组合式函数 (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 就可以了。

typescript 复制代码
// 拆分单元格
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

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

另外两个也类似,就不粘贴了。

然后选中的单元格会有高亮效果,在模版中 td 标签上

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

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

typescript 复制代码
// 当前选中的单元格集合
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
})

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

ts 复制代码
watch(selectedCells, (value, oldValue) => {
  if (isEqual(value, oldValue)) return
  emit('changeSelectedCells', selectedCells.value)
})

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

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

7、删除列/行

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

typescript 复制代码
// 删除一行
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 + 上下左右箭头。代码看起来还是比较好理解的

typescript 复制代码
// 表格快捷键监听
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 属性判断是否在进行输入法输入即可。

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

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

相关推荐
maxruan19 分钟前
PyTorch学习
人工智能·pytorch·python·学习
故事与他64523 分钟前
XSS_and_Mysql_file靶场攻略
前端·学习方法·xss
MYX_30930 分钟前
第三章 线型神经网络
深度学习·神经网络·学习·算法
_李小白33 分钟前
【Android Gradle学习笔记】第八天:NDK的使用
android·笔记·学习
莫的感情1 小时前
下载按钮点击一次却下载两个文件问题
前端
一个很帅的帅哥1 小时前
JavaScript事件循环
开发语言·前端·javascript
摇滚侠1 小时前
Spring Boot 3零基础教程,WEB 开发 自定义静态资源目录 笔记31
spring boot·笔记·后端·spring
摇滚侠1 小时前
Spring Boot 3零基础教程,WEB 开发 Thymeleaf 遍历 笔记40
spring boot·笔记·thymeleaf
小宁爱Python1 小时前
Django Web 开发系列(二):视图进阶、快捷函数与请求响应处理
前端·django·sqlite
fox_1 小时前
深入理解React中的不可变性:原理、价值与实践
前端·react.js