效果

背景
1、需要做管理对象的扩展信息管理,数据项不确定,且数据项存在任意嵌套,综合考虑只有excel的行为满足需求
2、markdown 对单元格合并支持度未知,不考虑
3、富文本,标准富文本体量有点大,且需要做很多禁用操作,不适合做小需求改造,不考虑,同理各种excel插件相对需求体量太大,也不考虑
4、之前做过基于标记清除的后处理式表格合并,想着看能不能自己写个满足需求的支持合并、增删的表格控件,有以下功能要求:1、插槽渲染 2、合并/拆分 3、增删行|列
结论
只是记录下思路,主要分主要对象管理:
1、数据展示层
2、操作层
3、计算层
觉得比较有意思的功能是:组合选区的界限判定以及选区指示器功能。
剩下行列宽高的调整,但是原始需求希望的是固定单元格尺寸,也没什么挑战,跳过,有闲时间了再加。或者考虑加个选取复制粘贴功能。
为了方便处理DOM和事件,直接用vue渲染。
代码
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.layout {
display: grid;
width: 400px;
grid-template-columns: repeat(3, calc(100% / 3));
}
table, td {
border: 1px solid #000;
}
table {
width: 100%;
border-collapse: collapse;
}
td {
/*padding: 5px;*/
padding: 0;
user-select: none;
height: 1px;
/*border-collapse: collapse;*/
}
td.collapse {
}
td.rangeHighLight {
/*background: #ddd !important;*/
box-shadow: rgba(14, 118, 128, 0.26) inset 0 0 50px 1px;
/*border: 1px solid orange;*/
}
.description {
min-width: 200px;
display: flex;
align-items: center;
}
.description .name {
display: flex;
width: 100px;
white-space: normal;
word-break: break-all;
overflow: hidden;
min-height: 40px;
height: fit-content;
/*background: rgba(182, 173, 173, 0.43);*/
flex-shrink: 0;
}
.description .value {
width: 100%;
min-height: 40px;
height: fit-content;
white-space: normal;
word-break: break-all;
overflow: hidden;
}
.inspector {
position: absolute;
pointer-events: none;
outline: 2px solid #129a69;
transition: all .1s ease-in-out;
z-index: 20;
box-sizing: border-box;
/*box-shadow: #000 0 0 2px 1px;*/
background: rgba(230, 230, 230, 0.17);
}
.inspector-ii {
background: #eeeeee;
text-align: center;
color: #585858;
}
</style>
</head>
<body>
<script src="./dist/vue.global.js"></script>
<div id="app">
<div>{{ message }}</div>
<button @click="group()">合并</button>
<button @click="unGroup()">取消合并</button>
<button @click="addRow()">添加行</button>
<button @click="addCol()">添加列</button>
<button @click="delRow()">删除行</button>
<button @click="delCol()">删除列</button>
<span>当前选择行:{{selectRi}}</span>
<span>当前选择列:{{selectCi}}</span>
<button @click="_=>selectRi = selectCi = null">取消行/列选择</button>
<div>{{inspector}}</div>
<div style="position: relative;overflow: scroll">
<table>
<tr>
<td></td>
<template v-for="(col,index) in table[0]">
<td class="inspector-ii" @click="selectCi = index">{{index}}</td>
</template>
</tr>
<template v-for="(row,index) in table">
<tr>
<td class="inspector-ii" @click="selectRi = index">{{index}}</td>
<template v-for="cell in row">
<td v-if="!cell.del"
:class="{
collapse:cell.collapse,
rangeHighLight:cell.rangeHighLight,
}"
@mousedown="rangeStart(cell,$event)"
@mousemove="range(cell,$event)"
@mouseup="rangeEnd(cell)"
@contextmenu=""
:ri="cell.ri"
:ci="cell.ci"
:colspan="cell.cs"
:rowspan="cell.rs">
<div class="description">
<!-- <div class="name" > {{cell.text}}:</div>-->
<div class="name" ></div>
<!-- <div class="value" >{{cell.collapse}}</div>-->
<div class="value" ></div>
<!-- <input type="text">-->
</div>
</td>
</template>
</tr>
</template>
</table>
<div class="inspector"
:style="{
left:inspector.x+'px',
top:inspector.y+'px',
width:inspector.width+'px',
height:inspector.height+'px'
}">
</div>
</div>
</div>
<script>
const {createApp} = Vue
const ITER_SAFE_MAX = 1000
let tableData = []
let rows = 20, cols = 10
for (let i = 0; i < rows; i++) {
let row = []
tableData.push(row)
for (let j = 0; j < cols; j++) {
row.push({
rs: 1, cs: 1,
ri: i, ci: j,
text: `text${i}${j}`,
del: false
})
}
}
const CurrentRange = {
start: null,
end: null,
startEl: null,
endEl: null
}
//
const GD = {
collapseRangeArr: []
}
let setCheckResult = null
let selection = null
/**
* 判断两个表格单元格是否有重叠
* @param {Object} cell1 - 第一个单元格,格式为 {ci: 列索引, ri: 行索引, cs: 合并列数, rs: 合并行数}
* @param {Object} cell2 - 第二个单元格,格式同 cell1
* @returns {boolean} - true 表示有重叠,false 表示无重叠
*/
function isCellsOverlap(cell1, cell2) {
// 计算单元格1的列范围和行范围
const cell1ColStart = cell1.ci;
const cell1ColEnd = cell1.ci + (cell1.cs || 1) - 1;
const cell1RowStart = cell1.ri;
const cell1RowEnd = cell1.ri + (cell1.rs || 1) - 1;
// 计算单元格2的列范围
const cell2ColStart = cell2.ci;
const cell2ColEnd = cell2.ci + (cell2.cs || 1) - 1;
const cell2RowStart = cell2.ri;
const cell2RowEnd = cell2.ri + (cell2.rs || 1) - 1;
// 判断列范围是否有重叠:一个单元格的结束列索引 >= 另一个单元格的起始列索引,并且起始列索引 <= 另一个单元格的结束列索引
const isColumnOverlap = cell1ColEnd >= cell2ColStart && cell1ColStart <= cell2ColEnd;
// 判断行范围是否有重叠:逻辑同上
const isRowOverlap = cell1RowEnd >= cell2RowStart && cell1RowStart <= cell2RowEnd;
// 只有列和行两个方向上都存在重叠,单元格才被视为有重叠
return isColumnOverlap && isRowOverlap;
}
//合并两个range
//{ci: 1, ri: 0, cs: 1, rs: 1}
//{ci: 1, ri: 3, cs: 1, rs: 1}
//{ci: 1, ri: 0, cs: 2, rs: 5}
function mergeCollapse(areaA, areaB) {
let temp = {
ci: Math.min(areaA.ci, areaB.ci),
ri: Math.min(areaA.ri, areaB.ri),
}
temp.cs = Math.abs(Math.max(areaA.ci + areaA.cs - 1, areaB.ci + areaB.cs - 1) - temp.ci) + 1
temp.rs = Math.abs(Math.max(areaA.ri + areaA.rs - 1, areaB.ri + areaB.rs - 1) - temp.ri) + 1
return temp
}
//range是否时单独cell
function rangeIsSingleCell(cellA, cellB) {
return cellA.ci === cellB.ci && cellA.ri === cellB.ri
}
/**
* 获取所有选区,包括可以与指定range合并的选区,不可与指定range合并的选区
* @param range
* @returns {*[]}
*/
function getCollapseRange(range) {
let collapseResult = null
let collapseDisableArr = []
let collapseRangeArr = []
//遍历所有合并属性块
let tempCollapseRangeArr = GD.collapseRangeArr.map(d => {
return {ci: d.ci, ri: d.ri, cs: d.cs, rs: d.rs}
})
//每次遍历找出并合并所有cover块直到没有可以合并的
for (let safeKey = 0; safeKey < ITER_SAFE_MAX; safeKey++) {
let unCover = []
for (let i = 0; i < tempCollapseRangeArr.length; i++) {
let iter = tempCollapseRangeArr[i]
let isOverlapping = isCellsOverlap(iter, range);
if (isOverlapping) {
range = mergeCollapse(range, iter)
} else {
unCover.push(iter)
}
}
if (unCover.length === tempCollapseRangeArr.length) {
// result = [...unCover, range]
collapseResult = range
collapseDisableArr = unCover
collapseRangeArr = [...unCover, collapseResult]
break
} else {
tempCollapseRangeArr = unCover
}
}
return {
collapseResult,
collapseDisableArr,
collapseRangeArr
}
}
function tableIterHandle(cb) {
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
cb(i, j)
}
}
}
/**
鼠标滑动,选择对应单元格,计算所有单元格 索引加合并确定的范围,将范围内的单元格合并成一个,合并单元各包括主单元格和副单元格,主单元格表现合并,副标记
合并数据为1,并且不可见
以当前选择区域,遍历所有合并单元格,如果存在重合,则合并并重新遍历
鼠标一次按下确定选取,对选取进行高亮
可以选择合并选取或者拆分合并
合并,拆分,添加列,删除列
数据分为表数据和 range数据
range
js环境实现,使用 requestAnimationFrame 实时获取表格的最新属性,
实现方法 highLightRange({ci,ri},{ci,ri}) ci,ri表示单元格的索引,
两组单元格索引确定一个选区,调用highLightRange方法时将移动一个div指示器刚好和选区一样大在选区的正上方
*/
createApp({
data() {
return {
message: 'Power By YuMao!',
columnLimit: 3,
list: ``,
table: tableData,
inspector: {
visible: false,
x: 0,
y: 0,
width: 0,
height: 0,
animationFrameKey: null
},
selectRi: null,
selectCi: null,
}
},
computed: {
result() {
const vm = this
return this.list.split('===')
}
},
mounted() {
const vm = this
setCheckResult = (checkRange) => {
//获取加上check选区后的所有合并选区
let {collapseRangeArr} = getCollapseRange(checkRange)
GD.collapseRangeArr = collapseRangeArr
//清除旧的合并标记
tableIterHandle((i, j) => {
vm.table[i][j].collapse = false
})
//遍历所有合并选取
vm.updateCollapse()
}
},
methods: {
rangeStart({ci, ri, cs, rs}, e) {
const vm = this
CurrentRange.start = {ci, ri, cs, rs}
tableIterHandle((i, j) => {
vm.table[i][j].rangeHighLight = false
})
},
rangeEnd() {
CurrentRange.start = null
CurrentRange.end = null
if (this.inspector.animationFrameKey) {
cancelAnimationFrame(this.inspector.animationFrameKey)
this.inspector.animationFrameKey = null
}
},
range({ci, ri, cs, rs}, e) {
const vm = this
if (!CurrentRange.start) {
return
}
CurrentRange.end = {ci, ri, cs, rs}
//建立选取,忽略选取只有一个单元格的情况
if (rangeIsSingleCell(CurrentRange.start, CurrentRange.end)) {
// return
}
//由两个单元格确定选区
let selectRange = mergeCollapse(CurrentRange.start, CurrentRange.end)
//获取与其他range合并后的选区
let {collapseResult} = getCollapseRange(selectRange)
this.inspector.visible = true
if (this.inspector.animationFrameKey) {
return
}
this.inspector.animationFrameKey = requestAnimationFrame(_ => {
console.log(2)
//遍历合并后选区内的所有的单元格,找到决定选区边界的两个可见单元格的索引
let iter00 = vm.table[collapseResult.ri][collapseResult.ci]
let maxRiCell = iter00, maxCiCell = iter00;
for (let rowKey = 0; rowKey < collapseResult.rs; rowKey++) {
for (let colKey = 0; colKey < collapseResult.cs; colKey++) {
let iter = vm.table[collapseResult.ri + rowKey][collapseResult.ci + colKey]
if (iter.del) {
continue
}
if ((iter.ri + iter.rs) > (maxRiCell.ri + maxRiCell.rs)) {
maxRiCell = iter
}
if ((iter.ci + iter.cs) > (maxCiCell.ci + maxCiCell.cs)) {
maxCiCell = iter
}
}
}
vm.table[maxRiCell.ri][maxRiCell.ci].rangeHighLight = true
vm.table[maxCiCell.ri][maxCiCell.ci].rangeHighLight = true
//获取对应单元格的位置尺寸
let cellEl_start = document.querySelector(`table td[ri = '${iter00.ri}'][ci = '${iter00.ci}']`)
let cellEl_ri = document.querySelector(`table td[ri = '${maxRiCell.ri}'][ci = '${maxRiCell.ci}']`)
let cellEl_ci = document.querySelector(`table td[ri = '${maxCiCell.ri}'][ci = '${maxCiCell.ci}']`)
this.inspector.x = cellEl_start.offsetLeft
this.inspector.y = cellEl_start.offsetTop
this.inspector.width = cellEl_ci.offsetWidth + cellEl_ci.offsetLeft - this.inspector.x
this.inspector.height = cellEl_ri.offsetHeight + cellEl_ri.offsetTop - this.inspector.y
cancelAnimationFrame(this.inspector.animationFrameKey)
this.inspector.animationFrameKey = null
})
//添加单元格高亮,先清除之前的高亮
tableIterHandle((i, j) => {
vm.table[i][j].rangeHighLight = false
})
//给选区单元格添加高亮
for (let rowKey = 0; rowKey < collapseResult.rs; rowKey++) {
for (let colKey = 0; colKey < collapseResult.cs; colKey++) {
let renderCell = vm.table[collapseResult.ri + rowKey][collapseResult.ci + colKey]
// renderCell.rangeHighLight = true
}
}
//缓存选区引用
selection = selectRange
},
//合并选区
group() {
const vm = this
if (!selection) {
return
}
setCheckResult(selection)
},
//拆解选区内所有合并range
unGroup() {
const vm = this
if (!selection) {
return
}
let tempCollapseRangeArr = []
GD.collapseRangeArr.forEach(cell => {
let isOverlapping = isCellsOverlap(cell, selection);
if (isOverlapping) {
//将合并选区内的所有cell设置为被合并
for (let rowKey = 0; rowKey < cell.rs; rowKey++) {
for (let colKey = 0; colKey < cell.cs; colKey++) {
let renderCell = vm.table[cell.ri + rowKey][cell.ci + colKey]
Object.assign(renderCell, {
del: false,
collapse: false,
cs: 1,
rs: 1,
text: ''
})
}
}
} else {
tempCollapseRangeArr.push(cell)
}
})
GD.collapseRangeArr = tempCollapseRangeArr
},
addRow() {
const vm = this
let ti = vm.selectRi
//元数据加入一
rows += 1
vm.table.splice(ti, 0, new Array(cols).fill(1).map((d, index) => {
return {
text: ``,
del: false
}
}))
vm.updateCellRiCi()
//如果range 完全在新增行之上,则不受影响
//如果range 完全在新增行之下,则ri+1
//如果range 穿过新增行,则ri不变,rs+1
//新增行不影响合并关系
//处理完所有range后更新table
GD.collapseRangeArr.forEach(cell => {
if (cell.ri > ti) {
//range在新增行之下
cell.ri += 1
return
}
if ((cell.ri + cell.rs) < ti) {
//range在新增行之上
return
}
if ((ti >= cell.ri) && (ti < (cell.ri + cell.rs))) {
//新增行穿过range
cell.rs += 1
}
})
vm.updateCollapse()
},
addCol() {
const vm = this
let ti = vm.selectCi
//元数据加入一
cols += 1
vm.table.forEach(row => {
row.splice(ti, 0, {
text: ``,
})
})
vm.updateCellRiCi()
//同 addRow
GD.collapseRangeArr.forEach(cell => {
if (cell.ci > ti) {
//range在新增行之下
cell.ci += 1
return
}
if ((cell.ci + cell.cs) < ti) {
//range在新增行之上
return
}
if ((ti >= cell.ci) && (ti < (cell.ci + cell.cs))) {
//新增行穿过range
cell.cs += 1
}
})
vm.updateCollapse()
},
delRow() {
const vm = this
let ti = vm.selectRi
//元数据加入一
rows -= 1
vm.table.splice(ti, 1)
vm.updateCellRiCi()
//如果range 完全在新增行之上,则不受影响
//如果range 完全在新增行之下,则ri-1
//如果range 穿过新增行,则ri不变,rs-1 若rs ===0 则去除range
//新增行不影响合并关系
//处理完所有range后更新table
let tempCollapseRangeArr = GD.collapseRangeArr.filter(cell => {
if (cell.ri > ti) {
//range在新增行之下
cell.ri -= 1
return true
}
if ((cell.ri + cell.rs) < ti) {
//range在新增行之上
return true
}
if ((ti >= cell.ri) && (ti <= (cell.ri + cell.rs))) {
//新增行穿过range
cell.rs -= 1
if (cell.rs === 0) {
return false
}
}
return true
})
GD.collapseRangeArr = tempCollapseRangeArr
vm.updateCollapse()
},
delCol() {
const vm = this
let ti = vm.selectCi
//元数据加入一
cols -= 1
vm.table.forEach(row => {
row.splice(ti, 1)
})
vm.updateCellRiCi()
//同delRow
let tempCollapseRangeArr = GD.collapseRangeArr.filter(cell => {
if (cell.ci > ti) {
//range在新增行之下
cell.ci -= 1
return true
}
if ((cell.ci + cell.cs) < ti) {
//range在新增行之上
return true
}
if ((ti >= cell.ci) && (ti <= (cell.ci + cell.cs))) {
//新增行穿过range
cell.cs -= 1
if (cell.cs === 0) {
return false
}
}
return true
})
GD.collapseRangeArr = tempCollapseRangeArr
vm.updateCollapse()
},
updateCollapse() {
const vm = this
GD.collapseRangeArr.forEach(cell => {
//将合并选区的第一个cell作为主cell
let firstCell = vm.table[cell.ri][cell.ci]
let firstText = firstCell.text
//将合并选区内的所有cell设置为被合并
for (let rowKey = 0; rowKey < cell.rs; rowKey++) {
for (let colKey = 0; colKey < cell.cs; colKey++) {
let renderCell = vm.table[cell.ri + rowKey][cell.ci + colKey]
Object.assign(renderCell, {
del: true,
collapse: true,
cs: 1,
rs: 1,
text: ''
})
}
}
Object.assign(firstCell, {
del: false,
collapse: true,
rs: cell.rs,
cs: cell.cs,
text: firstText
})
})
},
//更新合并信息前需要更新所有cell的ri,ci 以及重置collapse
updateCellRiCi() {
const vm = this
tableIterHandle((i, j) => {
Object.assign(vm.table[i][j], {
ri: i, ci: j,
rs: 1, cs: 1,
del: false,
collapse: false
})
})
}
}
}).mount('#app')
</script>
</body>
</html>