2021-03-31 17:01:03 +08:00

734 lines
21 KiB
Vue

<template>
<div
class="editable-table"
:style="{ width: totalWidth + 'px' }"
>
<div class="handler" v-if="editable">
<div
class="drag-line"
v-for="(pos, index) in dragLinePosition"
:key="index"
:style="{
left: pos + 'px',
}"
@mousedown="$event => handleMousedownColHandler($event, index)"
></div>
</div>
<table
:class="{
'theme': theme,
'row-header': theme?.rowHeader,
'row-footer': theme?.rowFooter,
'col-header': theme?.colHeader,
'col-footer': theme?.colFooter,
}"
:style="`--themeColor: ${theme?.color}; --subThemeColor1: ${subThemeColor[0]}; --subThemeColor2: ${subThemeColor[1]}`"
>
<colgroup>
<col span="1" v-for="(width, index) in colSizeList" :key="index" :width="width">
</colgroup>
<tbody>
<tr
v-for="(rowCells, rowIndex) in tableCells"
:key="rowIndex"
>
<td
class="cell"
:class="{
'selected': selectedCells.includes(`${rowIndex}_${colIndex}`) && selectedCells.length > 1,
'active': activedCell === `${rowIndex}_${colIndex}`,
}"
:style="{
borderStyle: outline.style,
borderColor: outline.color,
borderWidth: outline.width + 'px',
...getTextStyle(cell.style),
}"
v-for="(cell, colIndex) in rowCells"
:key="cell.id"
:rowspan="cell.rowspan"
:colspan="cell.colspan"
:data-cell-index="`${rowIndex}_${colIndex}`"
v-show="!hideCells.includes(`${rowIndex}_${colIndex}`)"
@mousedown="$event => handleCellMousedown($event, rowIndex, colIndex)"
@mouseenter="handleCellMouseenter(rowIndex, colIndex)"
v-contextmenu="el => contextmenus(el)"
>
<CustomTextarea
class="cell-text"
:class="{ 'active': activedCell === `${rowIndex}_${colIndex}` }"
:contenteditable="activedCell === `${rowIndex}_${colIndex}` ? 'plaintext-only' : false"
v-model="cell.text"
@update:modelValue="handleInput()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, nextTick, onMounted, onUnmounted, PropType, ref, watch } from 'vue'
import debounce from 'lodash/debounce'
import { useStore } from '@/store'
import { PPTElementOutline, TableCell, TableTheme } from '@/types/slides'
import { ContextmenuItem } from '@/components/Contextmenu/types'
import { KEYS } from '@/configs/hotkey'
import { createRandomCode } from '@/utils/common'
import { getTextStyle } from './utils'
import useHideCells from './useHideCells'
import useSubThemeColor from './useSubThemeColor'
import CustomTextarea from './CustomTextarea.vue'
export default defineComponent({
name: 'editable-table',
components: {
CustomTextarea,
},
props: {
data: {
type: Array as PropType<TableCell[][]>,
required: true,
},
width: {
type: Number,
required: true,
},
colWidths: {
type: Array as PropType<number[]>,
required: true,
},
outline: {
type: Object as PropType<PPTElementOutline>,
required: true,
},
theme: {
type: Object as PropType<TableTheme>,
},
editable: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const store = useStore()
const canvasScale = computed(() => store.state.canvasScale)
const isStartSelect = ref(false)
const startCell = ref<number[]>([])
const endCell = ref<number[]>([])
const tableCells = computed<TableCell[][]>({
get() {
return props.data
},
set(newData) {
emit('change', newData)
},
})
// 主题辅助色
const theme = computed(() => props.theme)
const { subThemeColor } = useSubThemeColor(theme)
// 计算表格每一列的列宽和总宽度
const colSizeList = ref<number[]>([])
const totalWidth = computed(() => colSizeList.value.reduce((a, b) => a + b))
watch([
() => props.colWidths,
() => props.width,
], () => {
colSizeList.value = props.colWidths.map(item => item * props.width)
}, { immediate: true })
// 清除全部单元格的选中状态
// 表格处于不可编辑状态时也需要清除
const removeSelectedCells = () => {
startCell.value = []
endCell.value = []
}
watch(() => props.editable, () => {
if (!props.editable) removeSelectedCells()
})
// 用于拖拽列宽的操作节点位置
const dragLinePosition = computed(() => {
const dragLinePosition: number[] = []
for (let i = 1; i < colSizeList.value.length + 1; i++) {
const pos = colSizeList.value.slice(0, i).reduce((a, b) => (a + b))
dragLinePosition.push(pos)
}
return dragLinePosition
})
// 无效的单元格位置(被合并的单元格位置)集合
const cells = computed(() => props.data)
const { hideCells } = useHideCells(cells)
// 当前选中的单元格集合
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, () => {
emit('changeSelectedCells', selectedCells.value)
})
// 当前激活的单元格:当且仅当只有一个选中单元格时,该单元格为激活的单元格
const activedCell = computed(() => {
if (selectedCells.value.length > 1) return null
return selectedCells.value[0]
})
// 当前选中的单元格位置范围
const selectedRange = computed(() => {
if (!startCell.value.length) return null
const [startX, startY] = startCell.value
if (!endCell.value.length) return { row: [startX, startX], col: [startY, startY] }
const [endX, endY] = endCell.value
if (startX === endX && startY === endY) return { row: [startX, startX], col: [startY, startY] }
const minX = Math.min(startX, endX)
const minY = Math.min(startY, endY)
const maxX = Math.max(startX, endX)
const maxY = Math.max(startY, endY)
return {
row: [minX, maxX],
col: [minY, maxY],
}
})
// 设置选中单元格状态(鼠标点击或拖选)
const handleMouseup = () => isStartSelect.value = false
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]
}
onMounted(() => {
document.addEventListener('mouseup', handleMouseup)
})
onUnmounted(() => {
document.removeEventListener('mouseup', handleMouseup)
})
// 判断某位置是否为无效单元格(被合并掉的位置)
const isHideCell = (rowIndex: number, colIndex: number) => hideCells.value.includes(`${rowIndex}_${colIndex}`)
// 选中指定的列
const selectCol = (index: number) => {
const maxRow = tableCells.value.length - 1
startCell.value = [0, index]
endCell.value = [maxRow, index]
}
// 选中指定的行
const selectRow = (index: number) => {
const maxCol = tableCells.value[index].length - 1
startCell.value = [index, 0]
endCell.value = [index, maxCol]
}
// 选中全部单元格
const selectAll = () => {
const maxRow = tableCells.value.length - 1
const maxCol = tableCells.value[maxRow].length - 1
startCell.value = [0, 0]
endCell.value = [maxRow, maxCol]
}
// 删除一行
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)
}
// 插入一行
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: createRandomCode(),
})
}
_tableCells.splice(rowIndex, 0, rowCells)
tableCells.value = _tableCells
}
// 插入一列
const insertCol = (colIndex: number) => {
tableCells.value = tableCells.value.map(item => {
const cell = {
colspan: 1,
rowspan: 1,
text: '',
id: createRandomCode(),
}
item.splice(colIndex, 0, cell)
return item
})
colSizeList.value.splice(colIndex, 0, 100)
emit('changeColWidths', colSizeList.value)
}
// 合并单元格
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))
_tableCells[minX][minY].rowspan = maxX - minX + 1
_tableCells[minX][minY].colspan = maxY - minY + 1
tableCells.value = _tableCells
removeSelectedCells()
}
// 拆分单元格
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.value = _tableCells
removeSelectedCells()
}
// 鼠标拖拽调整列宽
const handleMousedownColHandler = (e: MouseEvent, colIndex: number) => {
removeSelectedCells()
let isMouseDown = true
const originWidth = colSizeList.value[colIndex]
const startPageX = e.pageX
const minWidth = 50
document.onmousemove = e => {
if (!isMouseDown) return
const moveX = (e.pageX - startPageX) / canvasScale.value
const width = originWidth + moveX < minWidth ? minWidth : Math.round(originWidth + moveX)
colSizeList.value[colIndex] = width
}
document.onmouseup = () => {
isMouseDown = false
document.onmousemove = null
document.onmouseup = null
emit('changeColWidths', colSizeList.value)
}
}
// 清空选中单元格内的文字
const clearSelectedCellText = () => {
const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
for (let i = 0; i < _tableCells.length; i++) {
for (let j = 0; j < _tableCells[i].length; j++) {
if (selectedCells.value.includes(`${i}_${j}`)) {
_tableCells[i][j].text = ''
}
}
}
tableCells.value = _tableCells
}
// 将焦点移动到下一个单元格
// 当前行右边有单元格时,焦点右移
// 当前行右边无单元格(已处在行末),且存在下一行时,焦点移动下下一行行首
// 当前行右边无单元格(已处在行末),且不存在下一行(已处在最后一行)时,新建一行并将焦点移动下下一行行首
const tabActiveCell = () => {
const getNextCell = (i: number, j: number): [number, number] | null => {
if (!tableCells.value[i]) return null
if (!tableCells.value[i][j]) return getNextCell(i + 1, 0)
if (isHideCell(i, j)) return getNextCell(i, j + 1)
return [i, j]
}
endCell.value = []
const nextRow = startCell.value[0]
const nextCol = startCell.value[1] + 1
const nextCell = getNextCell(nextRow, nextCol)
if (!nextCell) {
insertRow(nextRow + 1)
startCell.value = [nextRow + 1, 0]
}
else startCell.value = nextCell
// 移动焦点后自动聚焦文本
nextTick(() => {
const textRef = document.querySelector('.cell-text.active') as HTMLInputElement
if (textRef) textRef.focus()
})
}
// 表格快捷键监听
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()
}
if (e.ctrlKey && key === KEYS.UP) {
e.preventDefault()
const rowIndex = +selectedCells.value[0].split('_')[0]
insertRow(rowIndex)
}
if (e.ctrlKey && key === KEYS.DOWN) {
e.preventDefault()
const rowIndex = +selectedCells.value[0].split('_')[0]
insertRow(rowIndex + 1)
}
if (e.ctrlKey && key === KEYS.LEFT) {
e.preventDefault()
const colIndex = +selectedCells.value[0].split('_')[1]
insertCol(colIndex)
}
if (e.ctrlKey && key === KEYS.RIGHT) {
e.preventDefault()
const colIndex = +selectedCells.value[0].split('_')[1]
insertCol(colIndex + 1)
}
}
else if (key === KEYS.DELETE) {
clearSelectedCellText()
}
}
onMounted(() => {
document.addEventListener('keydown', keydownListener)
})
onUnmounted(() => {
document.removeEventListener('keydown', keydownListener)
})
// 单元格文字输入时更新表格数据
const handleInput = debounce(function() {
emit('change', tableCells.value)
}, 300, { trailing: true })
// 获取有效的单元格(排除掉被合并的单元格)
const getEffectiveTableCells = () => {
const effectiveTableCells = []
for (let i = 0; i < tableCells.value.length; i++) {
const rowCells = tableCells.value[i]
const _rowCells = []
for (let j = 0; j < rowCells.length; j++) {
if (!isHideCell(i, j)) _rowCells.push(rowCells[j])
}
if (_rowCells.length) effectiveTableCells.push(_rowCells)
}
return effectiveTableCells
}
// 检查是否可以删除行和列:有效的行/列数大于1
const checkCanDeleteRowOrCol = () => {
const effectiveTableCells = getEffectiveTableCells()
const canDeleteRow = effectiveTableCells.length > 1
const canDeleteCol = effectiveTableCells[0].length > 1
return { canDeleteRow, canDeleteCol }
}
// 检查是否可以合并或拆分
// 必须多选才可以合并
// 必须单选且所选单元格为合并单元格才可以拆分
const checkCanMergeOrSplit = (rowIndex: number, colIndex: number) => {
const isMultiSelected = selectedCells.value.length > 1
const targetCell = tableCells.value[rowIndex][colIndex]
const canMerge = isMultiSelected
const canSplit = !isMultiSelected && (targetCell.rowspan > 1 || targetCell.colspan > 1)
return { canMerge, canSplit }
}
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,
},
]
}
return {
getTextStyle,
dragLinePosition,
tableCells,
colSizeList,
totalWidth,
hideCells,
selectedCells,
activedCell,
selectedRange,
handleCellMousedown,
handleCellMouseenter,
selectCol,
selectRow,
handleMousedownColHandler,
contextmenus,
handleInput,
subThemeColor,
}
},
})
</script>
<style lang="scss" scoped>
.editable-table {
position: relative;
user-select: none;
}
table {
width: 100%;
position: relative;
table-layout: fixed;
border-collapse: collapse;
border-spacing: 0;
border: 0;
word-wrap: break-word;
user-select: none;
--themeColor: $themeColor;
--subThemeColor1: $themeColor;
--subThemeColor2: $themeColor;
&.theme {
tr:nth-child(2n) .cell {
background-color: var(--subThemeColor1);
}
tr:nth-child(2n + 1) .cell {
background-color: var(--subThemeColor2);
}
&.row-header {
tr:first-child .cell {
background-color: var(--themeColor);
}
}
&.row-footer {
tr:last-child .cell {
background-color: var(--themeColor);
}
}
&.col-header {
tr .cell:first-child {
background-color: var(--themeColor);
}
}
&.col-footer {
tr .cell:last-child {
background-color: var(--themeColor);
}
}
}
tr {
height: 36px;
}
.cell {
position: relative;
white-space: normal;
word-wrap: break-word;
vertical-align: middle;
font-size: 14px;
cursor: default;
&.selected::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-color: rgba($color: #666, $alpha: .4);
}
}
.cell-text {
min-height: 32px;
padding: 5px;
line-height: 1.5;
user-select: none;
cursor: text;
&.active {
user-select: text;
}
}
}
.drag-line {
position: absolute;
top: 0;
bottom: 0;
width: 3px;
background-color: $themeColor;
margin-left: -1px;
opacity: 0;
z-index: 2;
cursor: col-resize;
&:hover {
opacity: 1;
}
}
</style>