|
|
@@ -0,0 +1,460 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="editable-table"
|
|
|
+ :style="{ width: width + 'px' }"
|
|
|
+ >
|
|
|
+ <table>
|
|
|
+ <colgroup>
|
|
|
+ <col span="1" v-for="(width, index) in colWidths" :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}`,
|
|
|
+ }"
|
|
|
+ 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)"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="cell-text"
|
|
|
+ :contenteditable="activedCell === `${rowIndex}_${colIndex}` ? 'plaintext-only' : false"
|
|
|
+ ></div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts">
|
|
|
+import { createRandomCode } from '@/utils/common'
|
|
|
+import { computed, defineComponent, onMounted, onUnmounted, ref } from 'vue'
|
|
|
+import { ContextmenuItem } from './Contextmenu/types'
|
|
|
+
|
|
|
+interface TableCells {
|
|
|
+ id: string;
|
|
|
+ colspan: number;
|
|
|
+ rowspan: number;
|
|
|
+ text: string;
|
|
|
+ style?: {
|
|
|
+ color?: string;
|
|
|
+ bgColor?: string;
|
|
|
+ fontSize?: number;
|
|
|
+ fontName?: string;
|
|
|
+ bold?: boolean;
|
|
|
+ italic?: boolean;
|
|
|
+ align?: string;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export default defineComponent({
|
|
|
+ name: 'editable-table',
|
|
|
+ setup() {
|
|
|
+ const tableCells = ref<TableCells[][]>([
|
|
|
+ [
|
|
|
+ { id: '1', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '2', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '3', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '4', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '5', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ { id: '6', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '7', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '8', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '9', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '10', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ { id: '11', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '12', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '13', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '14', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ { id: '15', colspan: 1, rowspan: 1, text: '' },
|
|
|
+ ],
|
|
|
+ ])
|
|
|
+ const width = 800
|
|
|
+ const colWidths = ref([160, 160, 160, 160, 160])
|
|
|
+ const isStartSelect = ref(false)
|
|
|
+ const startCell = ref<number[]>([])
|
|
|
+ const endCell = ref<number[]>([])
|
|
|
+
|
|
|
+ const hideCells = computed(() => {
|
|
|
+ const hideCells = []
|
|
|
+
|
|
|
+ for(let i = 0; i < tableCells.value.length; i++) {
|
|
|
+ const rowCells = tableCells.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++) {
|
|
|
+ hideCells.push(`${row}_${col}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return hideCells
|
|
|
+ })
|
|
|
+
|
|
|
+ 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
|
|
|
+ })
|
|
|
+
|
|
|
+ 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.which !== 1) return
|
|
|
+
|
|
|
+ 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 removeSelectedCells = () => {
|
|
|
+ startCell.value = []
|
|
|
+ endCell.value = []
|
|
|
+ }
|
|
|
+
|
|
|
+ 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: TableCells[][] = 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: TableCells[][] = 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
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const insertRow = (selectedIndex: number, rowIndex: number) => {
|
|
|
+ const rowCells: TableCells[] = []
|
|
|
+ for(let i = 0; i < tableCells.value[0].length; i++) {
|
|
|
+ rowCells.push({
|
|
|
+ colspan: 1,
|
|
|
+ rowspan: 1,
|
|
|
+ text: '',
|
|
|
+ id: createRandomCode(),
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ tableCells.value.splice(rowIndex, 0, rowCells)
|
|
|
+ }
|
|
|
+
|
|
|
+ const insertCol = (selectedIndex: number, colIndex: number) => {
|
|
|
+ tableCells.value = tableCells.value.map(item => {
|
|
|
+ const cell = {
|
|
|
+ colspan: 1,
|
|
|
+ rowspan: 1,
|
|
|
+ text: '',
|
|
|
+ id: createRandomCode(),
|
|
|
+ }
|
|
|
+ item.splice(colIndex, 0, cell)
|
|
|
+ return item
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ 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: TableCells[][] = 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: TableCells[][] = JSON.parse(JSON.stringify(tableCells.value))
|
|
|
+ _tableCells[rowIndex][colIndex].rowspan = 1
|
|
|
+ _tableCells[rowIndex][colIndex].colspan = 1
|
|
|
+
|
|
|
+ tableCells.value = _tableCells
|
|
|
+ removeSelectedCells()
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 isMultiSelected = selectedCells.value.length > 1
|
|
|
+
|
|
|
+ const targetCell = tableCells.value[rowIndex][colIndex]
|
|
|
+ const canSplit = targetCell.rowspan > 1 || targetCell.colspan > 1
|
|
|
+
|
|
|
+ const effectiveTableCells = getEffectiveTableCells()
|
|
|
+ const canDeleteRow = effectiveTableCells.length > 1
|
|
|
+ const canDeleteCol = effectiveTableCells[0].length > 1
|
|
|
+
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ text: '插入列',
|
|
|
+ children: [
|
|
|
+ { text: '到左侧', handler: () => insertCol(colIndex, colIndex) },
|
|
|
+ { text: '到右侧', handler: () => insertCol(colIndex, colIndex + 1) },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ text: '插入行',
|
|
|
+ children: [
|
|
|
+ { text: '到上方', handler: () => insertRow(rowIndex, rowIndex) },
|
|
|
+ { text: '到下方', handler: () => insertRow(rowIndex, rowIndex + 1) },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ text: '删除列',
|
|
|
+ disable: !canDeleteCol,
|
|
|
+ handler: () => deleteCol(colIndex),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ text: '删除行',
|
|
|
+ disable: !canDeleteRow,
|
|
|
+ handler: () => deleteRow(rowIndex),
|
|
|
+ },
|
|
|
+ { divider: true },
|
|
|
+ {
|
|
|
+ text: '合并单元格',
|
|
|
+ hide: !isMultiSelected,
|
|
|
+ handler: mergeCells,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ text: '取消合并单元格',
|
|
|
+ hide: isMultiSelected || !canSplit,
|
|
|
+ handler: () => splitCells(rowIndex, colIndex),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ text: '选中全部单元格',
|
|
|
+ handler: selectAll,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ return {
|
|
|
+ width,
|
|
|
+ tableCells,
|
|
|
+ colWidths,
|
|
|
+ hideCells,
|
|
|
+ selectedCells,
|
|
|
+ activedCell,
|
|
|
+ selectedRange,
|
|
|
+ handleCellMousedown,
|
|
|
+ handleCellMouseenter,
|
|
|
+ selectCol,
|
|
|
+ selectRow,
|
|
|
+ contextmenus,
|
|
|
+ }
|
|
|
+ },
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.editable-table {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+table {
|
|
|
+ width: 100%;
|
|
|
+ position: relative;
|
|
|
+ table-layout: fixed;
|
|
|
+ border-collapse: collapse;
|
|
|
+ border-spacing: 0;
|
|
|
+ word-wrap: break-word;
|
|
|
+
|
|
|
+ .cell {
|
|
|
+ padding: 5px;
|
|
|
+ position: relative;
|
|
|
+ white-space: normal;
|
|
|
+ word-wrap: break-word;
|
|
|
+ vertical-align: middle;
|
|
|
+ border: 1px solid #d9d9d9;
|
|
|
+ cursor: default;
|
|
|
+
|
|
|
+ &.selected::after {
|
|
|
+ content: '';
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ background-color: rgba($color: #888, $alpha: .1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .cell-text {
|
|
|
+ min-height: 22px;
|
|
|
+ border: 0;
|
|
|
+ outline: 0;
|
|
|
+ line-height: 1.5;
|
|
|
+ font-size: 14px;
|
|
|
+ user-select: none;
|
|
|
+ cursor: text;
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ user-select: text;
|
|
|
+ }
|
|
|
+
|
|
|
+ ::selection {
|
|
|
+ background-color: rgba(27, 110, 232, 0.3);
|
|
|
+ color: inherit;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|