基于原生table实现单元格合并、增删

效果

背景

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>
相关推荐
Irene19912 小时前
Prettier 配置文件 .prettierrc.js 和 .prettierrc.json 的区别
javascript·json
应茶茶2 小时前
从 C 到 C++:详解不定参数的两种实现方式(va_args 与参数包)
c语言·开发语言·c++
Data_agent2 小时前
1688获得1688店铺列表API,python请求示例
开发语言·python·算法
2301_764441333 小时前
使用python构建的应急物资代储博弈模型
开发语言·python·算法
丿BAIKAL巛3 小时前
Java前后端传参与接收全解析
java·开发语言
code bean3 小时前
【C++】Scoop 包管理器与 MinGW 工具链详解
开发语言·c++
cc蒲公英3 小时前
javascript有哪些内置对象
java·前端·javascript
yanghuashuiyue3 小时前
Java过滤器-拦截器-AOP-Controller
java·开发语言
小冷coding3 小时前
【Java】高并发架构设计:1000 QPS服务器配置与压测实战
java·服务器·开发语言