前言:这篇文章来探讨一下表格功能是怎么实现的吧!
一、插入表格
我们可以看到,鼠标移动到菜单项上出现的提示语是“插入表格”
那么就全局搜索一下,就发现这个菜单在 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 = 100const DEFAULT_CELL_HEIGHT = 36// 创建列宽数组 每个元素的值为1/colconst colWidths: number[] = new Array(col).fill(1 / col)const width = col * DEFAULT_CELL_WIDTHconst 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].elementsconst 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) returnlet 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 stringconst 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
方法,ContextmenuComponent
是 src/components/Contextmenu/index.vue
组件
createVNode
方法参数列表:
- type
类型: string | object
描述: VNode 的类型,可以是一个 HTML 标签名(如 ‘div’、‘span’ 等),也可以是一个组件的定义(如一个 Vue 组件的对象或异步组件的工厂函数)。 - props
类型: object | null
描述: 传递给组件或元素的属性。对于组件,这些属性会被作为 props 传递;对于 DOM 元素,这些属性会被直接应用到元素上。 - 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) returnif (item.children && !item.handler) returnif (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、合并单元格
这是比较复杂的功能了。它会修改最小的坐标处的单元格的 colSpan
和 rowspan
,表示当前这个单元格占多少单位行或者单位列。但是后面的单元格,不会删除,会隐藏掉。也就是说,二维数组的结构不变,只是其中的合并单元格的开头单元格的 rowSpan
和 colSpan
变了
下面这个单元格,就是被合并的效果,第一个单元格撑宽,第二个单元格 display: none
看一下被合并的单元格的数据,只有第一个格格的数据会被修改
// 合并单元格
const mergeCells = () => {const [startX, startY] = startCell.valueconst [endX, endY] = endCell.valueconst 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 + 1tableCells.value = _tableCellsremoveSelectedCells()
}
起始的单元格 startCell
在鼠标落下的时候会更新它的值
const handleCellMousedown = (e: MouseEvent, rowIndex: number, colIndex: number) => {if (e.button === 0) {endCell.value = []isStartSelect.value = truestartCell.value = [rowIndex, colIndex]}
}
结束的单元格在鼠标移入单元格的时候就会更新
const handleCellMouseenter = (rowIndex: number, colIndex: number) => {if (!isStartSelect.value) returnendCell.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、拆分单元格
这个方法就挺简单的了,之前我们合并单元格的时候,是把坐标最小的单元格的 rowSpan
和 colSpan
修改成合并单元格选中的横向格数和纵向格数。那么拆分单元格,直接把单元格的 rowSpan
和 colSpan
都变回 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 = 1tableCells.valuesremoveSelectedCells()
}
修改表格数据的方法,基本上都使用的是 tableCells.value
重新给表格数据赋值的方法。这也就确保上面的计算属性 hideCells
能触发更新。
6、选中当前列/行、选中全部单元格
选中这个操作,处理起来很简单,只是修改两个表示范围的响应式数据 startCell
、endCell
。
// 选中指定的列
const selectCol = (index: number) => {const maxRow = tableCells.value.length - 1startCell.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.valueif (!endCell.value.length) return [`${startX}_${startY}`]const [endX, endY] = endCell.valueif (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)) returnemit('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 - 1break}}}_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 - 1break}}}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) returnconst 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) returnconst key = e.key.toUpperCase()// ...
}
表格功能确实是很复杂啊,细节太多了。