需求背景:給現有的頁面加上標註解讀功標註一段文本的功能:選中一段文字,在光標結束位置旁邊彈出小tips,有一個按鈕表示添加解讀。添加了解讀後,那段文字高亮(加上下劃線)。此後每次頁面loaded,被加過標註的文字也要高亮html
效果圖: 前端
通常的實現方式是整個頁面內容html存起來,用一些特殊標記表示已經高亮:node
// magic-highlight表示高亮,高亮'666'
` <section> abc <a>def</a> <span>12334<magic-highlight id="1">666</magic-highlight>345</span> </section> `
複製代碼
渲染的時候,把特殊標記換成正確的html元素渲染便可react
可是如今問題來了,咱們這是一個現成的react頁面,是一個詳情頁,頁面的內容是多個接口返回填進去的:api
<section>
<h1>標題1</h1>
{接口1返回}
<h1>標題2</h1>
{接口2返回}
</section>
複製代碼
咱們若是高亮了接口2返回
的內容,那就意味着接口2返回的內容裏面有特殊標記:數組
// before
12334666345
// after
'12334<magic-highlight id="1">666</magic-highlight>345'
複製代碼
這裏會遇到一個很棘手的難點——修改、刪除的時候數據同步。由於你修改的時候展現到頁面的確定是字符串自己,修改後須要作字符串diff,再根據diff結果去同步這個帶
magic-highlight
的字符串,這個過程極其繁瑣,case不少。這一塊先放下,本身去看看selection和range相關的api,研究一下有沒有另外的解決方案app
執行getSelection()
後,會獲得一個selection對象,其中有一個getRangeAt
方法能夠獲取range對象。range對象有幾個屬性:dom
整個流程怎麼跑起來:ide
getSelection().getRangeAt(0)
獲取range對象(有時候會失敗,由於沒選,須要catch錯誤)基於這一套,服務端只須要存儲的信息是:光標起點位置、光標終點位置、所選文字,前端這邊徹底能夠實現全部的需求。下面開始從0到1實現post
先拉數據,獲取{ from, to, string, key }[]
高亮信息數組,key表示當前是什麼字段(如title、description)做爲索引
渲染每個字段的時候,從高亮信息數組裏面拿到對應的key,再根據from、to、string就能夠渲染
<span class="container">加了標註功能的這段文本</span>
複製代碼
下面class爲container的span統稱container。咱們這裏基於dangerouslySetInnerHTML來渲染的container:
function renderStringToDangerHTML(html: string, markList: Partial<MarkListItem>[]): string {
const indexMap = markList.reduce(
(acc, { from, to, cardId: id }) => {
(acc.from[from] || (acc.from[from] = [])).push(id);
(acc.to[to - 1] || (acc.to[to - 1] = [])).push(id);
return acc;
},
{ from: {}, to: {} }
);
return [].reduce.call(
html,
(acc, rune, idx) =>
`${acc}${(indexMap.from[idx] || []).reduce( (res, id) => `${res}<span id="lhyt-${id || `backup-${Math.random()}`}" data-id=${ id || Math.random() } class="${HIGHT_LIGHT_A_TAG_CLASS}">`, '' )}${rune}${(indexMap.to[idx] || []).reduce(res => `${res}</span>`, '')}`,
[]
);
}
// HIGHT_LIGHT_A_TAG_CLASS表示加上下劃線
複製代碼
渲染的時候:
// before
<h1>
title
</h1>
12334666345
// after
<h1>
title
</h1>
<span class="container">
{renderStringToDangerHTML('12334666345', [{ from: 5, to: 7, value: 666, key: 'title' }])}
</span>
複製代碼
經過container、startOffset和startContainer得到光標起點距離container全部的innertext的index。光標結束點同理
function getContainrtInnerTextIndexByBackward(container: Node, node: Node, initial = 0) {
let idx = initial;
let cur = node;
// 下面*表明光標
/** * <div><a>123</a>4*56</div> initial = 1 * <div><a>123</a><a>4*56</a></div> initial = 1 * <div>123<a>4*56</a></div> initial = 1 * <div>1234*56</div> initial = 4 */
while (cur !== container) {
Array.from(cur.parentNode.childNodes).find(child => {
if (child !== cur) {
// 多是element,多是文本節點,須要注意
const s = (child.innerText || child.data).length;
idx += s;
}
return child === cur;
});
cur = cur.parentNode;
}
return idx;
}
const startIndex = getContainrtInnerTextIndexByBackward(container, startContainer, startOffset);
const endIndex = getContainrtInnerTextIndexByBackward(container, endContainer, endOffset);
複製代碼
爲何不直接用selection對象的anchorOffset, focusOffset?
anchorOffset
和focusOffset
表示的是起點index和終點index。在多段落的時候,這兩個數值只是相對於當前段落,因此會不許確。而一行文字的時候的確是沒什麼問題,所以須要咱們本身實現一下這個回溯獲取index的功能
已經獲取到index,再獲取container下第index個字符串距離左上角的距離
但注意鼠標選擇的方向:從右往左、從左往右。從右往左須要取startindex,從左往右取endindex
解釋:
anchorOffset
和focusOffset
表示的是起點index和終點index,這兩個key的值完全按照鼠標順序的,若是從後面開始選,起點index < 結束index。range對象就不會有這個狀況,會按照文本流順序,但沒法知道方向了。
思路也很簡單,拷貝一份元素,fixed到左上角,透明。先拿innertext再把第index個變成span包裹,而後渲染innerhtml,最後拿到這個span的getboundingclientrect,就是準確的位置了
function getTextOffset(ele: HTMLElement, start: number, end: number) {
const newNode = ele.cloneNode(true);
const styles = getComputedStyle(ele);
Object.assign(newNode.style, {
...Array.from(styles)
.reduce((acc, key) => {
acc[key] = styles[key];
return acc;
}, {}),
position: 'fixed',
pointerEvents: 'none',
opacity: 0,
top: 0,
left: 0,
});
const uid = Math.random().toString(36).slice(2);
const temp = document.createElement('div');
const NEW_LINE_PLACE_HOLDER = `${Math.random().toString(36).slice(2)}-lhyt`;
temp.innerHTML = ele.innerHTML.replace(/\n/g, NEW_LINE_PLACE_HOLDER);
const realText = temp.innerText.replace(RegExp(NEW_LINE_PLACE_HOLDER, 'g'), '\n');
// 是不是從右邊選到左邊
const isReverse = start > end;
// 01234
// abcde
// d => b, start = 3, end = 1, from = end
// b => d, start = 1, end = 3, from = start
const from = isReverse ? Math.min(start, end) : Math.max(start, end) - 1;
newNode.innerHTML = `${realText.slice(0, from)}<span id="${uid}">${realText.slice( from, from + 1 )}</span>${realText.slice(from + 1)}`;
document.body.appendChild(newNode);
const mesureEle = document.getElementById(uid);
const ret = mesureEle.getBoundingClientRect();
removeElement(mesureEle, newNode); // 刪掉這些輔助元素
return ret;
}
複製代碼
根據位置渲染小tips。補充一下,前面所說的container是relative定位的,正是爲了讓彈層absolute定位。思路很簡單,但問題來了,react下如何掛到dangerouslySetInnerHTML渲染出來的container下?
很天然的回想到,使用reactDOM.createPortal
,很相似原生js的appendChild,掛在container下。當選擇完成,渲染了container,拿到它的ref引用,再setstate(當前container元素)
頁面內操做徹底沒問題,但問題來了,當props改變,須要刪除元素的時候,馬上報錯了。由於react下進行原生js操做是很危險的,從新渲染,刪除元素的時候分分鐘頁面白屏——a不是b的子節點。詳細問題分析可見 上一篇文章
其實,使用reactDOM.createPortal
的確是不科學,由於dangerouslySetInnerHTML
的結果須要用原生js獲取到container,而後setstate,經過reactDOM.createPortal
把小tips掛在container下。這個操做過程,夾雜react+原生js,當遇到各類複雜的state、props變化,整個組件從新渲染,新的innerhtml,刪除createPortal
產生的節點的瞬間,由於它真實的父節點也不在了,最後就報錯
原生仍是和原生一塊兒,react仍是和react一塊兒,因此這一塊只須要container.appendChild便可。
這樣的狀況下,一切手動來解決,先append,當state、props變化的時候,又把它刪除,這些全是原生js操做,並且都在container裏面作的,徹底能夠不直接碰到react的state相關的信息
// before
const RenderPopover: React.FC<RenderPopoverProps> = ({ rect, onTipsClick = () => {}, container }) => {
// portal渲染的組件返回的react元素
return rect && createPortal(
<aside style={style} id="lhyt-selection-portal" onClick={onTipsClick}> <span>xxx</span> </aside>,
container
)
};
// 改一下組件
const RenderPopover: React.FC<{}> = ({ rect, onTipsClick = () => {}, container }) => {
const { left, top } = rect || {};
// 涉及dom操做用useLayoutEffect
React.useLayoutEffect(() => {
const aside = document.createElement('aside');
// left還有一個細節:相似popover,在很靠左是bottomleft,很靠右是bottomright,中間就中間
Object.assign(aside.style, {
left: `${left}px`,
top: `${top}px`,
width: `${currentWidth}px`,
});
aside.onclick = onTipsClick;
aside.id = 'lhyt-selection-portal';
// 本來這就是portal渲染的組件返回的react元素
// 如今所有換成原生js字符串拼接 + 原生的dom操做
aside.innerHTML = ` <span> xxxxx </span> `;
container.appendChild(aside);
return () => {
aside.parentElement.removeChild(aside);
};
});
return <span />; }; 複製代碼
雖然是組件,但其實是一個空殼子,核心全是原生js操做,把小tips掛到container下。本來設計是一個組件,實際上應該作成一個hook的,改起來也很簡單,就不說了
關注公衆號《不同的前端》,以不同的視角學習前端,快速成長,一塊兒把玩最新的技術、探索各類黑科技