小程序原生电子书方案(后台编辑+C端展示)

85 阅读5分钟

最近出了个需求,要搞个电子书,上来就说对标wx读书。也能做,我们给产品提了个建议:去wx读书挖个人过来,一点问题都没有...

言归正传。B端要求导入书之后可以编辑、打标、打样式。我这一想,这不就是富文本嘛。结果评审的时候,app端上说他们用不了富文本。他们方案为纯画出来一张图去渲染,富文本的话还要遍历拆标签,很是复杂。于是B端只能换方案。

既然是挨个字的画。那只需要给出源内容字符串和对应字的样式即可。但不能单个字对应,由于app和小程序是一套数据源,小程序顶不住单个字渲染标签的性能,只能按区间给样式。调研过程中发现有个jsAPI能直接拿到选中区域的dom:window.getSelection()。好吧、之前确实没注意到这个api,于是方案有了:只需要拿到选中区域的起始和结束位置记住就ok了。这个api很强大一点在于能直接拿到偏移,因为字符串匹配肯定是不行的,有重复的直接就错了。 demo如下:思路为用拿到的选中区偏移量和指定根DOM节点计算出有效偏移量,且只计算纯文本节点的偏移(这是因为选中之后要加样式,会用span替换原来文本,不忽略标签的话会导致位置错乱)。最后生成一个list,每一项记录了一段区间的样式。且位置和源字符串位置严格对应。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<div>
    <div onclick="onReslove({color: 'red'})">red</div>
    <br>

    <div onclick="onReslove({color: 'green'})">green</div>
    <br>

    <div onclick="onReslove({color: 'blue'})">blue</div>
    <br>

    <div>underLine</div>
</div>
<div id="box" class="box">
    <!-- <div id="content">
        0123
        <a href="">
            45
        </a>
        <span>
            67
        </span>
        8 9
        <div>
            0
        </div>
    </div>
    <div id="333" class="b">
        12345672345y12345
    </div> -->
</div>

<body>
    <script>
        let isEnter = false

        let rangeList = []

        var onReslove = null

        let sourceText = '转身离开,你有话说不出来,海鸟跟鱼相爱只是一场意外,我们的爱,给的爱,差异一直存在,回不来,风中尘埃,等待竟累积成伤害,转身离开,分手说不出来,蔚蓝的珊瑚海,错过瞬间苍白,当初彼此,你我都不够成熟坦白,不应该,热情不在,你的笑容勉强不来,爱深埋珊瑚海。'

        document.getElementById('box').innerHTML = sourceText

        // 递归获取所有文本节点
        function getTextNodes(node, nodes = []) {
            if (node.nodeType === Node.TEXT_NODE) {
                nodes.push(node);
            } else {
                Array.from(node.childNodes).forEach(child => {
                    getTextNodes(child, nodes);
                });
            }
            return nodes;
        }

        // 计算全局索引(忽略空白字符)
        function calculateGlobalIndex(textNodes, targetNode, offset) {
            let globalIndex = 0;
            for (const node of textNodes) {
                // 当前文本节点的内容(去除所有空白字符)
                const processedText = node.textContent.replace(/\s/g, '');
                if (node === targetNode) {
                    // 将原始偏移量转换为有效偏移量(统计非空白字符数量)
                    let effectiveOffset = 0;
                    for (let i = 0; i < offset; i++) {
                        if (node.textContent[i] && !/\s/.test(node.textContent[i])) {
                            effectiveOffset++;
                        }
                    }
                    return globalIndex + effectiveOffset;
                }
                // 累加处理后的文本长度
                globalIndex += processedText.length;
            }
            return -1;
        }


        // document.addEventListener('mousedown', (e) => {
        //     console.log(e, '按下')
        // })
        document.getElementById('box').addEventListener('mousedown', (e) => {
            console.log(e, '按下 box')
            isEnter = true
        })

        document.addEventListener('mouseup', (e) => {
            console.log(e, '释放 box')
            isEnter = false
        })

        // 监听鼠标释放事件
        document.getElementById('box').addEventListener('mouseup', (e) => {
            if (!isEnter) {
                console.log('未在指定区域按下')
                return
            }
            isEnter = false
            const selection = window.getSelection();
            console.log(e, '释放')
            if (selection.isCollapsed) return;

            const range = selection.getRangeAt(0);
            const container = document.getElementById('box');
            const textNodes = getTextNodes(container);

            // 计算全局位置
            const startIndex = calculateGlobalIndex(textNodes, range.startContainer, range.startOffset);
            const endIndex = calculateGlobalIndex(textNodes, range.endContainer, range.endOffset);

            console.log(range, startIndex, endIndex)
            if (startIndex === -1 || endIndex === -1) return;

            // 确保顺序正确
            const [start, end] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex];
            const fullText = textNodes.map(node => node.textContent.replace(/\s/g, '')).join('');

            console.log("选中文本:", fullText.slice(start, end));
            console.log("全局位置:", `[${start}, ${end}]`);
            const promise = new Promise((resolve, reject) => {
                onReslove = resolve
            })
            promise.then((res) => {
                addNewRange(rangeList, { start, end: end - 1, style: res })
            })
        });

        function addNewRange(optRangeList, newRange) {
            if (optRangeList.length === 0) {
                rangeList = [newRange]
                renderDom()
                return
            }
            let lsRangeList = []

            let optNewRange = newRange

            // start end 不代表slice规则,提现的就是实际位置 slice的话,end需+1
            optRangeList.forEach((range) => {
                if (optNewRange.start === -1) {
                    lsRangeList.push(range)
                    return
                }
                if (optNewRange.start > range.end || optNewRange.end < range.start) {
                    lsRangeList.push(range)
                } else {
                    // optNewRange.start < range.end && optNewRange.end > range.start else已知条件
                    // 当前  3 --- 7 
                    // 新  2 ----------9
                    if (optNewRange.start <= range.start) {

                        if (optNewRange.start < range.start) {
                            lsRangeList.push({
                                start: optNewRange.start,
                                end: range.start - 1,
                                style: optNewRange.style
                            })
                            optNewRange.start = range.start
                        }

                        if (optNewRange.end < range.end) {
                            lsRangeList.push({
                                start: range.start,
                                end: optNewRange.end,
                                style: { ...range.style, ...optNewRange.style }
                            })
                            lsRangeList.push({
                                start: optNewRange.end + 1,
                                end: range.end,
                                style: range.style
                            })
                            optNewRange.start = -1
                        }
                        if (optNewRange.end >= range.end) {
                            lsRangeList.push({
                                start: range.start,
                                end: range.end,
                                style: { ...range.style, ...optNewRange.style }
                            })
                            if (optNewRange.end > range.end) {
                                optNewRange.start = range.end + 1

                                // lsRangeList.push({
                                //     start: range.end + 1,
                                //     end: optNewRange.end,
                                //     style: optNewRange.style
                                // })
                            } else {
                                optNewRange.start = -1
                            }
                        }

                    } else {
                        // 当前  3 --- 7        3 ---   7    
                        // 新       6--- -9        4-6
                        lsRangeList.push({
                            start: range.start,
                            end: optNewRange.start - 1,
                            style: range.style
                        })

                        if (optNewRange.end <= range.end) {
                            lsRangeList.push({ ...optNewRange })
                            if (optNewRange.end < range.end) {
                                lsRangeList.push({
                                    start: optNewRange.end + 1,
                                    end: range.end,
                                    style: range.style
                                })
                            }
                            optNewRange.start = -1
                        }

                        if (optNewRange.end > range.end) {
                            lsRangeList.push({
                                start: optNewRange.start,
                                end: range.end,
                                style: { ...range.style, ...optNewRange.style }
                            })
                            optNewRange.start = range.end + 1
                            // lsRangeList.push({
                            //     start: range.end + 1,
                            //     end: optNewRange.end,
                            //     style: optNewRange.style
                            // })
                        }
                    }
                }
            })
            if (optNewRange.start !== -1) {
                lsRangeList.push(optNewRange)
            }
            rangeList = lsRangeList
            renderDom()
        }

        function renderDom() {
            const parentDiv = document.getElementById('box');
            let html = '';
            let lastEnd = 0;
            const sortedRanges = [...rangeList].sort((a, b) => a.start - b.start);
            console.log(sortedRanges, 'sortedRanges')
            for (const range of sortedRanges) {
                if (range.start > lastEnd) {
                    html += escapeHtml(sourceText.slice(lastEnd, range.start));
                }
                const text = sourceText.slice(range.start, range.end + 1);
                html += `<span style="color: ${range.style.color}">${escapeHtml(text)}</span>`;
                lastEnd = range.end + 1;
            }
            if (lastEnd < sourceText.length) {
                html += escapeHtml(sourceText.slice(lastEnd));
            }
            parentDiv.innerHTML = html;
        }
        function escapeHtml(str) {
            return str
        }
    </script>
</body>

</html>

<style>
    .b {
        margin-top: 100px;
    }

    .box {
        height: 360px;
        width: 360px;
        border: 1px solid pink;
        margin: 100px auto;
        padding: 24px;
        line-height: 1.5;
    }
</style>

B端实现思路基本如上。性能上由于最小处理单位为节。所以不会有很多内容。

小程序C端渲染:由于小程序端没有做笔记功能,只需要将内容展示出来即可,至此想出了两种思路:

一种是根据数据源将源字符串结合样式list生成html字符串。即上述renderDom方法思路类似。然后用三方库towxml处理,将点击事件打进去,通过委托能拿到点击元素的所有属性。从而进一步处理。但这样处理后层级过深,且被shadow-root包裹,一些样式不好统一控制

二是写dom模板,虽然看似动态生成节点,实则用到的样式、事件就几种,写出对应的模板。将样式数组处理好,直接for循环出来。遍历一遍源样式数组,生成这种格式(也就是将源字符串都拆成这种区间,包括无任何样式和事件的),type对应事件类型、样式color等,具体没全写出来。

    // B端给的样式list对应下方只有后两项。通过处理一遍,将没处理的文本也加进去对应位置
    // 即可遍历一遍将所有内容渲染出来
    sourceText: '缓缓飘落的枫叶像思念,为何挽回要赶在冬天来之前,激光掠夺天边,北风掠过想你的容颜',
    rowList: [
      {
        hasStyle: false,
        text: '缓缓飘落的'
      },
      {
        color: 'red',
        type: '',
        hasStyle: true,
        text: '枫叶像思念,为何挽回要赶在冬天来之前'
      },
      {
        color: 'blue',
        type: 'video',
        hasStyle: true,
        text: ',激光掠夺天边,北风掠过想你的容颜'
      },
    ]