EditableTable.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  1. <template>
  2. <div
  3. class="editable-table"
  4. :style="{ width: totalWidth + 'px' }"
  5. >
  6. <div class="handler" v-if="editable">
  7. <div
  8. class="drag-line"
  9. v-for="(pos, index) in dragLinePosition"
  10. :key="index"
  11. :style="{ left: pos + 'px' }"
  12. @mousedown="$event => handleMousedownColHandler($event, index)"
  13. ></div>
  14. </div>
  15. <table
  16. :class="{
  17. 'theme': theme,
  18. 'row-header': theme?.rowHeader,
  19. 'row-footer': theme?.rowFooter,
  20. 'col-header': theme?.colHeader,
  21. 'col-footer': theme?.colFooter,
  22. }"
  23. :style="`--themeColor: ${theme?.color}; --subThemeColor1: ${subThemeColor[0]}; --subThemeColor2: ${subThemeColor[1]}`"
  24. >
  25. <colgroup>
  26. <col span="1" v-for="(width, index) in colSizeList" :key="index" :width="width">
  27. </colgroup>
  28. <tbody>
  29. <tr v-for="(rowCells, rowIndex) in tableCells" :key="rowIndex" :style="{ height: cellMinHeight + 'px' }">
  30. <td
  31. class="cell"
  32. :class="{
  33. 'selected': selectedCells.includes(`${rowIndex}_${colIndex}`) && selectedCells.length > 1,
  34. 'active': activedCell === `${rowIndex}_${colIndex}`,
  35. }"
  36. :style="{
  37. borderStyle: outline.style,
  38. borderColor: outline.color,
  39. borderWidth: outline.width + 'px',
  40. ...getTextStyle(cell.style),
  41. }"
  42. v-for="(cell, colIndex) in rowCells"
  43. :key="cell.id"
  44. :rowspan="cell.rowspan"
  45. :colspan="cell.colspan"
  46. :data-cell-index="`${rowIndex}_${colIndex}`"
  47. v-show="!hideCells.includes(`${rowIndex}_${colIndex}`)"
  48. @mousedown="$event => handleCellMousedown($event, rowIndex, colIndex)"
  49. @mouseenter="handleCellMouseenter(rowIndex, colIndex)"
  50. v-contextmenu="(el: HTMLElement) => contextmenus(el)"
  51. >
  52. <CustomTextarea
  53. v-if="activedCell === `${rowIndex}_${colIndex}`"
  54. class="cell-text"
  55. :class="{ 'active': activedCell === `${rowIndex}_${colIndex}` }"
  56. :style="{ minHeight: (cellMinHeight - 4) + 'px' }"
  57. :value="cell.text"
  58. @updateValue="value => handleInput(value, rowIndex, colIndex)"
  59. @insertExcelData="value => insertExcelData(value, rowIndex, colIndex)"
  60. />
  61. <div v-else class="cell-text" :style="{ minHeight: (cellMinHeight - 4) + 'px' }" v-html="formatText(cell.text)" />
  62. </td>
  63. </tr>
  64. </tbody>
  65. </table>
  66. </div>
  67. </template>
  68. <script lang="ts" setup>
  69. import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
  70. import { debounce, isEqual } from 'lodash'
  71. import { storeToRefs } from 'pinia'
  72. import { nanoid } from 'nanoid'
  73. import { useMainStore } from '@/store'
  74. import type { PPTElementOutline, TableCell, TableTheme } from '@/types/slides'
  75. import type { ContextmenuItem } from '@/components/Contextmenu/types'
  76. import { KEYS } from '@/configs/hotkey'
  77. import { getTextStyle, formatText } from './utils'
  78. import useHideCells from './useHideCells'
  79. import useSubThemeColor from './useSubThemeColor'
  80. import CustomTextarea from './CustomTextarea.vue'
  81. const props = withDefaults(defineProps<{
  82. data: TableCell[][]
  83. width: number
  84. cellMinHeight: number
  85. colWidths: number[]
  86. outline: PPTElementOutline
  87. theme?: TableTheme
  88. editable?: boolean
  89. }>(), {
  90. editable: true,
  91. })
  92. const emit = defineEmits<{
  93. (event: 'change', payload: TableCell[][]): void
  94. (event: 'changeColWidths', payload: number[]): void
  95. (event: 'changeSelectedCells', payload: string[]): void
  96. }>()
  97. const { canvasScale } = storeToRefs(useMainStore())
  98. const isStartSelect = ref(false)
  99. const startCell = ref<number[]>([])
  100. const endCell = ref<number[]>([])
  101. const tableCells = computed<TableCell[][]>({
  102. get() {
  103. return props.data
  104. },
  105. set(newData) {
  106. emit('change', newData)
  107. },
  108. })
  109. // 主题辅助色
  110. const theme = computed(() => props.theme)
  111. const { subThemeColor } = useSubThemeColor(theme)
  112. // 计算表格每一列的列宽和总宽度
  113. const colSizeList = ref<number[]>([])
  114. const totalWidth = computed(() => colSizeList.value.reduce((a, b) => a + b))
  115. watch([
  116. () => props.colWidths,
  117. () => props.width,
  118. ], () => {
  119. colSizeList.value = props.colWidths.map(item => item * props.width)
  120. }, { immediate: true })
  121. // 清除全部单元格的选中状态
  122. // 表格处于不可编辑状态时也需要清除
  123. const removeSelectedCells = () => {
  124. startCell.value = []
  125. endCell.value = []
  126. }
  127. watch(() => props.editable, () => {
  128. if (!props.editable) removeSelectedCells()
  129. })
  130. // 用于拖拽列宽的操作节点位置
  131. const dragLinePosition = computed(() => {
  132. const dragLinePosition: number[] = []
  133. for (let i = 1; i < colSizeList.value.length + 1; i++) {
  134. const pos = colSizeList.value.slice(0, i).reduce((a, b) => (a + b))
  135. dragLinePosition.push(pos)
  136. }
  137. return dragLinePosition
  138. })
  139. // 无效的单元格位置(被合并的单元格位置)集合
  140. const cells = computed(() => props.data)
  141. const { hideCells } = useHideCells(cells)
  142. // 当前选中的单元格集合
  143. const selectedCells = computed(() => {
  144. if (!startCell.value.length) return []
  145. const [startX, startY] = startCell.value
  146. if (!endCell.value.length) return [`${startX}_${startY}`]
  147. const [endX, endY] = endCell.value
  148. if (startX === endX && startY === endY) return [`${startX}_${startY}`]
  149. const selectedCells = []
  150. const minX = Math.min(startX, endX)
  151. const minY = Math.min(startY, endY)
  152. const maxX = Math.max(startX, endX)
  153. const maxY = Math.max(startY, endY)
  154. for (let i = 0; i < tableCells.value.length; i++) {
  155. const rowCells = tableCells.value[i]
  156. for (let j = 0; j < rowCells.length; j++) {
  157. if (i >= minX && i <= maxX && j >= minY && j <= maxY) selectedCells.push(`${i}_${j}`)
  158. }
  159. }
  160. return selectedCells
  161. })
  162. watch(selectedCells, (value, oldValue) => {
  163. if (isEqual(value, oldValue)) return
  164. emit('changeSelectedCells', selectedCells.value)
  165. })
  166. // 当前激活的单元格:当且仅当只有一个选中单元格时,该单元格为激活的单元格
  167. const activedCell = computed(() => {
  168. if (selectedCells.value.length > 1) return null
  169. return selectedCells.value[0]
  170. })
  171. // 设置选中单元格状态(鼠标点击或拖选)
  172. const handleMouseup = () => isStartSelect.value = false
  173. const handleCellMousedown = (e: MouseEvent, rowIndex: number, colIndex: number) => {
  174. if (e.button === 0) {
  175. endCell.value = []
  176. isStartSelect.value = true
  177. startCell.value = [rowIndex, colIndex]
  178. }
  179. }
  180. const handleCellMouseenter = (rowIndex: number, colIndex: number) => {
  181. if (!isStartSelect.value) return
  182. endCell.value = [rowIndex, colIndex]
  183. }
  184. onMounted(() => {
  185. document.addEventListener('mouseup', handleMouseup)
  186. })
  187. onUnmounted(() => {
  188. document.removeEventListener('mouseup', handleMouseup)
  189. })
  190. // 判断某位置是否为无效单元格(被合并掉的位置)
  191. const isHideCell = (rowIndex: number, colIndex: number) => hideCells.value.includes(`${rowIndex}_${colIndex}`)
  192. // 选中指定的列
  193. const selectCol = (index: number) => {
  194. const maxRow = tableCells.value.length - 1
  195. startCell.value = [0, index]
  196. endCell.value = [maxRow, index]
  197. }
  198. // 选中指定的行
  199. const selectRow = (index: number) => {
  200. const maxCol = tableCells.value[index].length - 1
  201. startCell.value = [index, 0]
  202. endCell.value = [index, maxCol]
  203. }
  204. // 选中全部单元格
  205. const selectAll = () => {
  206. const maxRow = tableCells.value.length - 1
  207. const maxCol = tableCells.value[maxRow].length - 1
  208. startCell.value = [0, 0]
  209. endCell.value = [maxRow, maxCol]
  210. }
  211. // 删除一行
  212. const deleteRow = (rowIndex: number) => {
  213. const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  214. const targetCells = tableCells.value[rowIndex]
  215. const hideCellsPos = []
  216. for (let i = 0; i < targetCells.length; i++) {
  217. if (isHideCell(rowIndex, i)) hideCellsPos.push(i)
  218. }
  219. for (const pos of hideCellsPos) {
  220. for (let i = rowIndex; i >= 0; i--) {
  221. if (!isHideCell(i, pos)) {
  222. _tableCells[i][pos].rowspan = _tableCells[i][pos].rowspan - 1
  223. break
  224. }
  225. }
  226. }
  227. _tableCells.splice(rowIndex, 1)
  228. tableCells.value = _tableCells
  229. }
  230. // 删除一列
  231. const deleteCol = (colIndex: number) => {
  232. const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  233. const hideCellsPos = []
  234. for (let i = 0; i < tableCells.value.length; i++) {
  235. if (isHideCell(i, colIndex)) hideCellsPos.push(i)
  236. }
  237. for (const pos of hideCellsPos) {
  238. for (let i = colIndex; i >= 0; i--) {
  239. if (!isHideCell(pos, i)) {
  240. _tableCells[pos][i].colspan = _tableCells[pos][i].colspan - 1
  241. break
  242. }
  243. }
  244. }
  245. tableCells.value = _tableCells.map(item => {
  246. item.splice(colIndex, 1)
  247. return item
  248. })
  249. colSizeList.value.splice(colIndex, 1)
  250. emit('changeColWidths', colSizeList.value)
  251. }
  252. // 插入一行
  253. const insertRow = (rowIndex: number) => {
  254. const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  255. const rowCells: TableCell[] = []
  256. for (let i = 0; i < _tableCells[0].length; i++) {
  257. rowCells.push({
  258. colspan: 1,
  259. rowspan: 1,
  260. text: '',
  261. id: nanoid(10),
  262. })
  263. }
  264. _tableCells.splice(rowIndex, 0, rowCells)
  265. tableCells.value = _tableCells
  266. }
  267. // 插入一列
  268. const insertCol = (colIndex: number) => {
  269. tableCells.value = tableCells.value.map(item => {
  270. const cell = {
  271. colspan: 1,
  272. rowspan: 1,
  273. text: '',
  274. id: nanoid(10),
  275. }
  276. item.splice(colIndex, 0, cell)
  277. return item
  278. })
  279. colSizeList.value.splice(colIndex, 0, 100)
  280. emit('changeColWidths', colSizeList.value)
  281. }
  282. // 填充指定的行/列数
  283. const fillTable = (rowCount: number, colCount: number) => {
  284. let _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  285. const defaultCell = { colspan: 1, rowspan: 1, text: '' }
  286. if (rowCount) {
  287. const newRows = []
  288. for (let i = 0; i < rowCount; i++) {
  289. const rowCells: TableCell[] = []
  290. for (let j = 0; j < _tableCells[0].length; j++) {
  291. rowCells.push({
  292. ...defaultCell,
  293. id: nanoid(10),
  294. })
  295. }
  296. newRows.push(rowCells)
  297. }
  298. _tableCells = [..._tableCells, ...newRows]
  299. }
  300. if (colCount) {
  301. _tableCells = _tableCells.map(item => {
  302. const cells: TableCell[] = []
  303. for (let i = 0; i < colCount; i++) {
  304. const cell = {
  305. ...defaultCell,
  306. id: nanoid(10),
  307. }
  308. cells.push(cell)
  309. }
  310. return [...item, ...cells]
  311. })
  312. colSizeList.value = [...colSizeList.value, ...new Array(colCount).fill(100)]
  313. emit('changeColWidths', colSizeList.value)
  314. }
  315. tableCells.value = _tableCells
  316. }
  317. // 合并单元格
  318. const mergeCells = () => {
  319. const [startX, startY] = startCell.value
  320. const [endX, endY] = endCell.value
  321. const minX = Math.min(startX, endX)
  322. const minY = Math.min(startY, endY)
  323. const maxX = Math.max(startX, endX)
  324. const maxY = Math.max(startY, endY)
  325. const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  326. _tableCells[minX][minY].rowspan = maxX - minX + 1
  327. _tableCells[minX][minY].colspan = maxY - minY + 1
  328. tableCells.value = _tableCells
  329. removeSelectedCells()
  330. }
  331. // 拆分单元格
  332. const splitCells = (rowIndex: number, colIndex: number) => {
  333. const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  334. _tableCells[rowIndex][colIndex].rowspan = 1
  335. _tableCells[rowIndex][colIndex].colspan = 1
  336. tableCells.value = _tableCells
  337. removeSelectedCells()
  338. }
  339. // 鼠标拖拽调整列宽
  340. const handleMousedownColHandler = (e: MouseEvent, colIndex: number) => {
  341. removeSelectedCells()
  342. let isMouseDown = true
  343. const originWidth = colSizeList.value[colIndex]
  344. const startPageX = e.pageX
  345. const minWidth = 50
  346. document.onmousemove = e => {
  347. if (!isMouseDown) return
  348. const moveX = (e.pageX - startPageX) / canvasScale.value
  349. const width = originWidth + moveX < minWidth ? minWidth : Math.round(originWidth + moveX)
  350. colSizeList.value[colIndex] = width
  351. }
  352. document.onmouseup = () => {
  353. isMouseDown = false
  354. document.onmousemove = null
  355. document.onmouseup = null
  356. emit('changeColWidths', colSizeList.value)
  357. }
  358. }
  359. // 清空选中单元格内的文字
  360. const clearSelectedCellText = () => {
  361. const _tableCells: TableCell[][] = JSON.parse(JSON.stringify(tableCells.value))
  362. for (let i = 0; i < _tableCells.length; i++) {
  363. for (let j = 0; j < _tableCells[i].length; j++) {
  364. if (selectedCells.value.includes(`${i}_${j}`)) {
  365. _tableCells[i][j].text = ''
  366. }
  367. }
  368. }
  369. tableCells.value = _tableCells
  370. }
  371. const focusActiveCell = () => {
  372. nextTick(() => {
  373. const textRef = document.querySelector('.cell-text.active') as HTMLInputElement
  374. if (textRef) textRef.focus()
  375. })
  376. }
  377. // 将焦点移动到下一个单元格
  378. // 当前行右边有单元格时,焦点右移
  379. // 当前行右边无单元格(已处在行末),且存在下一行时,焦点移动至下一行行首
  380. // 当前行右边无单元格(已处在行末),且不存在下一行(已处在最后一行)时,新建一行并将焦点移动至下一行行首
  381. const tabActiveCell = () => {
  382. const getNextCell = (i: number, j: number): [number, number] | null => {
  383. if (!tableCells.value[i]) return null
  384. if (!tableCells.value[i][j]) return getNextCell(i + 1, 0)
  385. if (isHideCell(i, j)) return getNextCell(i, j + 1)
  386. return [i, j]
  387. }
  388. endCell.value = []
  389. const nextRow = startCell.value[0]
  390. const nextCol = startCell.value[1] + 1
  391. const nextCell = getNextCell(nextRow, nextCol)
  392. if (!nextCell) {
  393. insertRow(nextRow + 1)
  394. startCell.value = [nextRow + 1, 0]
  395. }
  396. else startCell.value = nextCell
  397. // 移动焦点后自动聚焦文本
  398. focusActiveCell()
  399. }
  400. // 移动焦点(上下左右)
  401. const moveActiveCell = (dir: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT') => {
  402. const rowIndex = +selectedCells.value[0].split('_')[0]
  403. const colIndex = +selectedCells.value[0].split('_')[1]
  404. const rowLen = tableCells.value.length
  405. const colLen = tableCells.value[0].length
  406. const getEffectivePos = (pos: [number, number]): [number, number] => {
  407. if (pos[0] < 0 || pos[1] < 0 || pos[0] > rowLen - 1 || pos[1] > colLen - 1) return [0, 0]
  408. const p = `${pos[0]}_${pos[1]}`
  409. if (!hideCells.value.includes(p)) return pos
  410. if (dir === 'UP') {
  411. return getEffectivePos([pos[0], pos[1] - 1])
  412. }
  413. if (dir === 'DOWN') {
  414. return getEffectivePos([pos[0], pos[1] - 1])
  415. }
  416. if (dir === 'LEFT') {
  417. return getEffectivePos([pos[0] - 1, pos[1]])
  418. }
  419. if (dir === 'RIGHT') {
  420. return getEffectivePos([pos[0] - 1, pos[1]])
  421. }
  422. return [0, 0]
  423. }
  424. if (dir === 'UP') {
  425. const _rowIndex = rowIndex - 1
  426. if (_rowIndex < 0) return
  427. endCell.value = []
  428. startCell.value = getEffectivePos([_rowIndex, colIndex])
  429. }
  430. else if (dir === 'DOWN') {
  431. const _rowIndex = rowIndex + 1
  432. if (_rowIndex > rowLen - 1) return
  433. endCell.value = []
  434. startCell.value = getEffectivePos([_rowIndex, colIndex])
  435. }
  436. else if (dir === 'LEFT') {
  437. const _colIndex = colIndex - 1
  438. if (_colIndex < 0) return
  439. endCell.value = []
  440. startCell.value = getEffectivePos([rowIndex, _colIndex])
  441. }
  442. else if (dir === 'RIGHT') {
  443. const _colIndex = colIndex + 1
  444. if (_colIndex > colLen - 1) return
  445. endCell.value = []
  446. startCell.value = getEffectivePos([rowIndex, _colIndex])
  447. }
  448. focusActiveCell()
  449. }
  450. // 获取光标位置
  451. const getCaretPosition = (element: HTMLDivElement) => {
  452. const selection = window.getSelection()
  453. if (selection && selection.rangeCount > 0) {
  454. const range = selection.getRangeAt(0)
  455. const preCaretRange = range.cloneRange()
  456. preCaretRange.selectNodeContents(element)
  457. preCaretRange.setEnd(range.startContainer, range.startOffset)
  458. const start = preCaretRange.toString().length
  459. preCaretRange.setEnd(range.endContainer, range.endOffset)
  460. const end = preCaretRange.toString().length
  461. const len = element.textContent?.length || 0
  462. return { start, end, len }
  463. }
  464. return null
  465. }
  466. // 表格快捷键监听
  467. const keydownListener = (e: KeyboardEvent) => {
  468. if (!props.editable || !selectedCells.value.length) return
  469. const key = e.key.toUpperCase()
  470. if (selectedCells.value.length < 2) {
  471. if (key === KEYS.TAB) {
  472. e.preventDefault()
  473. tabActiveCell()
  474. }
  475. else if (e.ctrlKey && key === KEYS.UP) {
  476. e.preventDefault()
  477. const rowIndex = +selectedCells.value[0].split('_')[0]
  478. insertRow(rowIndex)
  479. }
  480. else if (e.ctrlKey && key === KEYS.DOWN) {
  481. e.preventDefault()
  482. const rowIndex = +selectedCells.value[0].split('_')[0]
  483. insertRow(rowIndex + 1)
  484. }
  485. else if (e.ctrlKey && key === KEYS.LEFT) {
  486. e.preventDefault()
  487. const colIndex = +selectedCells.value[0].split('_')[1]
  488. insertCol(colIndex)
  489. }
  490. else if (e.ctrlKey && key === KEYS.RIGHT) {
  491. e.preventDefault()
  492. const colIndex = +selectedCells.value[0].split('_')[1]
  493. insertCol(colIndex + 1)
  494. }
  495. else if (key === KEYS.UP) {
  496. const range = getCaretPosition(e.target as HTMLDivElement)
  497. if (range && range.start === range.end && range.start === 0) {
  498. moveActiveCell('UP')
  499. }
  500. }
  501. else if (key === KEYS.DOWN) {
  502. const range = getCaretPosition(e.target as HTMLDivElement)
  503. if (range && range.start === range.end && range.start === range.len) {
  504. moveActiveCell('DOWN')
  505. }
  506. }
  507. else if (key === KEYS.LEFT) {
  508. const range = getCaretPosition(e.target as HTMLDivElement)
  509. if (range && range.start === range.end && range.start === 0) {
  510. moveActiveCell('LEFT')
  511. }
  512. }
  513. else if (key === KEYS.RIGHT) {
  514. const range = getCaretPosition(e.target as HTMLDivElement)
  515. if (range && range.start === range.end && range.start === range.len) {
  516. moveActiveCell('RIGHT')
  517. }
  518. }
  519. }
  520. else if (key === KEYS.DELETE) {
  521. clearSelectedCellText()
  522. }
  523. }
  524. onMounted(() => {
  525. document.addEventListener('keydown', keydownListener)
  526. })
  527. onUnmounted(() => {
  528. document.removeEventListener('keydown', keydownListener)
  529. })
  530. // 单元格文字输入时更新表格数据
  531. const handleInput = debounce(function(value, rowIndex, colIndex) {
  532. tableCells.value[rowIndex][colIndex].text = value
  533. emit('change', tableCells.value)
  534. }, 300, { trailing: true })
  535. // 插入来自Excel的数据,表格的行/列数不够时自动补足
  536. const insertExcelData = (data: string[][], rowIndex: number, colIndex: number) => {
  537. const maxRow = data.length
  538. const maxCol = data[0].length
  539. let fillRowCount = 0
  540. let fillColCount = 0
  541. if (rowIndex + maxRow > tableCells.value.length) fillRowCount = rowIndex + maxRow - tableCells.value.length
  542. if (colIndex + maxCol > tableCells.value[0].length) fillColCount = colIndex + maxCol - tableCells.value[0].length
  543. if (fillRowCount || fillColCount) fillTable(fillRowCount, fillColCount)
  544. nextTick(() => {
  545. for (let i = 0; i < maxRow; i++) {
  546. for (let j = 0; j < maxCol; j++) {
  547. if (tableCells.value[rowIndex + i][colIndex + j]) {
  548. tableCells.value[rowIndex + i][colIndex + j].text = data[i][j]
  549. }
  550. }
  551. }
  552. emit('change', tableCells.value)
  553. })
  554. }
  555. // 获取有效的单元格(排除掉被合并的单元格)
  556. const getEffectiveTableCells = () => {
  557. const effectiveTableCells = []
  558. for (let i = 0; i < tableCells.value.length; i++) {
  559. const rowCells = tableCells.value[i]
  560. const _rowCells = []
  561. for (let j = 0; j < rowCells.length; j++) {
  562. if (!isHideCell(i, j)) _rowCells.push(rowCells[j])
  563. }
  564. if (_rowCells.length) effectiveTableCells.push(_rowCells)
  565. }
  566. return effectiveTableCells
  567. }
  568. // 检查是否可以删除行和列:有效的行/列数大于1
  569. const checkCanDeleteRowOrCol = () => {
  570. const effectiveTableCells = getEffectiveTableCells()
  571. const canDeleteRow = effectiveTableCells.length > 1
  572. const canDeleteCol = effectiveTableCells[0].length > 1
  573. return { canDeleteRow, canDeleteCol }
  574. }
  575. // 检查是否可以合并或拆分
  576. // 必须多选才可以合并
  577. // 必须单选且所选单元格为合并单元格才可以拆分
  578. const checkCanMergeOrSplit = (rowIndex: number, colIndex: number) => {
  579. const isMultiSelected = selectedCells.value.length > 1
  580. const targetCell = tableCells.value[rowIndex][colIndex]
  581. const canMerge = isMultiSelected
  582. const canSplit = !isMultiSelected && (targetCell.rowspan > 1 || targetCell.colspan > 1)
  583. return { canMerge, canSplit }
  584. }
  585. const contextmenus = (el: HTMLElement): ContextmenuItem[] => {
  586. const cellIndex = el.dataset.cellIndex as string
  587. const rowIndex = +cellIndex.split('_')[0]
  588. const colIndex = +cellIndex.split('_')[1]
  589. if (!selectedCells.value.includes(`${rowIndex}_${colIndex}`)) {
  590. startCell.value = [rowIndex, colIndex]
  591. endCell.value = []
  592. }
  593. const { canMerge, canSplit } = checkCanMergeOrSplit(rowIndex, colIndex)
  594. const { canDeleteRow, canDeleteCol } = checkCanDeleteRowOrCol()
  595. return [
  596. {
  597. text: '插入列',
  598. children: [
  599. { text: '到左侧', handler: () => insertCol(colIndex) },
  600. { text: '到右侧', handler: () => insertCol(colIndex + 1) },
  601. ],
  602. },
  603. {
  604. text: '插入行',
  605. children: [
  606. { text: '到上方', handler: () => insertRow(rowIndex) },
  607. { text: '到下方', handler: () => insertRow(rowIndex + 1) },
  608. ],
  609. },
  610. {
  611. text: '删除列',
  612. disable: !canDeleteCol,
  613. handler: () => deleteCol(colIndex),
  614. },
  615. {
  616. text: '删除行',
  617. disable: !canDeleteRow,
  618. handler: () => deleteRow(rowIndex),
  619. },
  620. { divider: true },
  621. {
  622. text: '合并单元格',
  623. disable: !canMerge,
  624. handler: mergeCells,
  625. },
  626. {
  627. text: '取消合并单元格',
  628. disable: !canSplit,
  629. handler: () => splitCells(rowIndex, colIndex),
  630. },
  631. { divider: true },
  632. {
  633. text: '选中当前列',
  634. handler: () => selectCol(colIndex),
  635. },
  636. {
  637. text: '选中当前行',
  638. handler: () => selectRow(rowIndex),
  639. },
  640. {
  641. text: '选中全部单元格',
  642. handler: selectAll,
  643. },
  644. ]
  645. }
  646. </script>
  647. <style lang="scss" scoped>
  648. .editable-table {
  649. position: relative;
  650. user-select: none;
  651. }
  652. table {
  653. width: 100%;
  654. position: relative;
  655. table-layout: fixed;
  656. border-collapse: collapse;
  657. border-spacing: 0;
  658. border: 0;
  659. word-wrap: break-word;
  660. user-select: none;
  661. --themeColor: $themeColor;
  662. --subThemeColor1: $themeColor;
  663. --subThemeColor2: $themeColor;
  664. &.theme {
  665. background-color: #fff;
  666. tr:nth-child(2n) .cell {
  667. background-color: var(--subThemeColor1);
  668. }
  669. tr:nth-child(2n + 1) .cell {
  670. background-color: var(--subThemeColor2);
  671. }
  672. &.row-header {
  673. tr:first-child .cell {
  674. background-color: var(--themeColor);
  675. }
  676. }
  677. &.row-footer {
  678. tr:last-child .cell {
  679. background-color: var(--themeColor);
  680. }
  681. }
  682. &.col-header {
  683. tr .cell:first-child {
  684. background-color: var(--themeColor);
  685. }
  686. }
  687. &.col-footer {
  688. tr .cell:last-child {
  689. background-color: var(--themeColor);
  690. }
  691. }
  692. }
  693. .cell {
  694. position: relative;
  695. white-space: break-spaces;
  696. word-wrap: break-word;
  697. vertical-align: middle;
  698. font-size: 14px;
  699. background-clip: padding-box;
  700. cursor: default;
  701. &.selected::after {
  702. content: '';
  703. width: 100%;
  704. height: 100%;
  705. position: absolute;
  706. top: 0;
  707. left: 0;
  708. background-color: rgba($color: #666, $alpha: .4);
  709. }
  710. }
  711. .cell-text {
  712. padding: 5px;
  713. line-height: 1.15;
  714. user-select: none;
  715. cursor: text;
  716. &.active {
  717. user-select: text;
  718. }
  719. }
  720. }
  721. .drag-line {
  722. position: absolute;
  723. top: 0;
  724. bottom: 0;
  725. width: 3px;
  726. background-color: $themeColor;
  727. margin-left: -1px;
  728. opacity: 0;
  729. z-index: 2;
  730. cursor: col-resize;
  731. }
  732. </style>