【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
方法,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) 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、合并单元格
这是比较复杂的功能了。它会修改最小的坐标处的单元格的 colSpan
和 rowspan
,表示当前这个单元格占多少单位行或者单位列。但是后面的单元格,不会删除,会隐藏掉。也就是说,二维数组的结构不变,只是其中的合并单元格的开头单元格的 rowSpan
和 colSpan
变了
下面这个单元格,就是被合并的效果,第一个单元格撑宽,第二个单元格 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、拆分单元格
这个方法就挺简单的了,之前我们合并单元格的时候,是把坐标最小的单元格的 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 = 1
tableCells.values
removeSelectedCells()
}
修改表格数据的方法,基本上都使用的是 tableCells.value
重新给表格数据赋值的方法。这也就确保上面的计算属性 hideCells
能触发更新。
6、选中当前列/行、选中全部单元格
选中这个操作,处理起来很简单,只是修改两个表示范围的响应式数据 startCell
、endCell
。
// 选中指定的列
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()
// ...
}
表格功能确实是很复杂啊,细节太多了。