最近出了个需求,要搞个电子书,上来就说对标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: ',激光掠夺天边,北风掠过想你的容颜'
},
]