要件の背景:テキストの段落をマークする注釈解釈機能に加えて、既存のページに:テキストの段落を選択し、カーソルのポップアップ小さなヒントの終了位置の横に、解釈を追加するボタンがあります。解釈を追加した後、テキストが強調表示されます。その後、ページが読み込まれるたびに、マークアップに追加されたテキストも強調表示される必要があります。
レンダリング:
実装分析
これを実現する通常の方法は、ページのコンテンツ全体をhtmlに保存し、ハイライトされていることを示すために特別なマークアップを使用することです。
// magic-highlightはハイライトを示し、'666'をハイライトする。
`
<section>
abc
<a>def</a>
<span>12334<magic-highlight id="1">666</magic-highlight>345</span>
</section>
`
レンダリング時には、特別なタグを正しいhtml要素に置き換えるだけでレンダリングできます。
しかし、今問題が起こりました。これは既製の反応ページ、詳細ページで、ページの内容は複数のインターフェイスを埋めるために戻ります:
<section>
<h1> </h1>
{インターフェイス1は以下を返す}
<h1> </h1>
{インターフェース2 return}
</section>
インターフェース2によって返されたコンテンツがハイライトされている場合、インターフェース2によって返されたコンテンツの中に特別なマーカーがあることを意味します:
// before
12334666345
// after
'12334<magic-highlight id="1">666</magic-highlight>345'
ここで、非常にやっかいな難題に遭遇します - 修正と削除時のデータの同期です。ページへの表示を変更する場合は、確かに文字列そのものであるため、文字列の差分の変更後に行う必要があり、その後、マジックハイライトと文字列を同期するために差分の結果に応じて、このプロセスは非常に面倒であり、多くの場合。この作品は、最初に置く、彼らは選択と範囲関連のAPIを見に行くと、別の解決策があるかどうかを検討します!
選択に基づいて & range
getSelection()を実行すると、getRangeAtメソッドで範囲オブジェクトを取得した選択オブジェクトが得られます:
- commonAncestorContainer: パブリック親コンテナ
- startContainer: カーソルの開始コンテナ
- endContainer: カーソルの終了コンテナ
- startOffset: 開始コンテナ内のテキストの開始位置からのカーソルインデックスの距離。
- endOffset: 終了コンテナ内のテキストの先頭からのカーソルのインデックス
プロセス全体の仕組み
getSelection().getRangeAt(0)selectionchangeイベントをリッスンし、0.8秒間アンチシェイクし、それを処理する際に範囲オブジェクトを取得するために使用します。- コンテナ内のすべての内部テキストからの相対的な単語のインデックスを取得します。
- コンテナの左上隅からのインデックス文字の距離を取得します。
- 選択したテキストの終端カーソルのちょうど下にポップアップウィンドウをフックします。
カーソルの開始位置、カーソルの終了位置、選択されたテキスト、フロントエンドのフロントエンド側は完全にすべてのニーズを達成することができます:このセットに基づいて、サーバー側は、情報を格納する必要があります。次の0から1を達成するために開始します。
フロントエンドページがロードされます
{ from, to, string, key }[]最初にデータを取り出し、ハイライト情報の配列を取得。キーは、現在インデックスとして使用されているフィールドを示します。
各フィールドをレンダリングする際には、ハイライトされた情報の配列の中から対応するキーを取得し、from, to, stringに基づいてレンダリングします。
<span class="container">アノテーション機能を持つテキスト</span>
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>
イベントをバインド
- クリックすると詳細が表示されます: イベントリスナーはドキュメントの下にぶら下がり、イベントプロキシを使用して、強調表示されたテキストがクリックされたかどうか、表示された注釈、下線テキストと背景を判断します。レンダリングには補完 ID があるため、この情報は既知です。選択要素のネイティブな dom 操作と、アクティブなアクティブ化クラス。クリックが他の場所にある場合、それらのアクティブな要素をすべてアクティブに解除します。
コンテナの内部テキストから開始カーソルと終了カーソルのインデックスを取得します。
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) {
// 要素かもしれないし、テキストノードかもしれない。
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);
なぜ選択オブジェクトのanchorOffsetとfocusOffsetを使わないのですか?
anchorOffsetとfocusOffsetは開始インデックスと終了インデックスを表しますが、複数の段落がある場合、この2つの値は現在の段落からの相対値でしかないので、不正確になります。そして、テキストの行は確かに問題ありませんので、関数のインデックスを取得するために、このバックを実装する必要があります。
左上隅からのインデックス文字列の距離
インデックスを取得したら、左上隅からコンテナの下にあるインデックスの文字列の距離を取得します。
ただし、マウスの選択方向に注意してください:右から左、左から右。右から左は startindex、左から右は endindex を取ります。
説明:anchorOffsetとfocusOffsetは、開始インデックスと終了インデックスを表し、これらの2つのキーの値は、マウスの順序に従って徹底的に、選択範囲の後ろから開始した場合、開始インデックス<終了インデックスです。
アイデアも非常に簡単です、要素のコピーをコピーし、左上隅に固定し、透明。最初にinnertextを取得し、次に最初のインデックスを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(/
/g, NEW_LINE_PLACE_HOLDER);
const realText = temp.innerText.replace(RegExp(NEW_LINE_PLACE_HOLDER, 'g'), '
');
// 右から左に選択するかどうか
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;
}
position tip.に基づくレンダリングについて補足すると、コンテナが相対的に配置されるという前述は、まさにポップアップレイヤーを絶対的に配置するためのものです。アイデアは単純ですが、コンテナの下にレンダリングされたdangerouslySetInnerHTMLにどのように反応するかという疑問が生じます。
コンテナの下に見つける方法小さなヒント
reactDOM.createPortalコンテナの下にぶら下がっている、ネイティブjsのappendChildに非常によく似たものを使うことを思い出すのは自然なことです。選択が終わると、コンテナはレンダリングされ、そのrefへの参照を取得し、setstate(現在のコンテナ要素)
ページ内の操作は全く問題ありませんが、問題はpropsが変更され、要素を削除する必要がある場合に発生します。ネイティブjsの操作の下でreactは非常に危険であるため、再レンダリング、ページの白い画面の分 - aはbの子ノードではありませんときに要素を削除します。問題の詳細な分析は、 見つけることができます
reactDOM.createPortaldangerouslySetInnerHTMLreactDOM.createPortalコンテナの下に小さなヒントをハングアップするためにネイティブjsを使用してコンテナに取得し、setstate、スルーを使用する必要があるため、実際には、使用して、確かに非科学的です。この操作プロセスは、リアクト+ネイティブjsを散在させ、複雑な状態の様々な、小道具の変更、コンポーネント全体の再レンダリング、新しいinnerhtmlに遭遇したときに、その本当の親ノードも存在しないため、現時点ではcreatePortalによって生成されたノードを削除し、最終的にエラーを報告し
NativeはNativeのまま、reactはreactのままなので、この部分にはcontainer.appendChildが必要なだけです。
この場合、すべて手動で解決し、最初に追加し、状態、propsが変更されたときに、それを削除し、これらはすべてネイティブのjs操作であり、コンテナ内で行われ、直接reactの状態に関連する情報に触れることはできません!
// before
const RenderPopover: React.FC<RenderPopoverProps> = ({ rect, onTipsClick = () => {}, container }) => {
// portalリアクト要素によって返されるレンダリングコンポーネント
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また、詳細がある:ポップオーバーと同様に、左端は左下、右端は右下、中央の真ん中は真ん中である
Object.assign(aside.style, {
left: `${left}px`,
top: `${top}px`,
width: `${currentWidth}px`,
});
aside.onclick = onTipsClick;
aside.id = 'lhyt-selection-portal';
// もともとこれは、ポータルのレンダリングコンポーネントは、リアクト要素に返される
// これで、すべてがネイティブjsの文字列スプライシングに変更された + ネイティブdom操作
aside.innerHTML = `
<span>
xxxxx
</span>
`;
container.appendChild(aside);
return () => {
aside.parentElement.removeChild(aside);
};
});
return <span />;
};
それはコンポーネントですが、実際には空のシェルですが、コアはすべてネイティブのjs操作、コンテナの下にハングアップする小さな先端です。元のデザインはコンポーネントですが、実際には、フックにする必要があり、それはまた、非常にシンプルである変更は言うまでもありません。
最後に
- この小さな機能はほんの一瞬使われるだけで、実装プロセスは複雑で、より多くの知識を必要とします。
- ステートとプロップへの直接リンクを避けるために、reactの下でネイティブjsを使用します。
- reactでnative jsを使う場合、reactの操作とnative jsのdom操作は厳密に分離されており、混在させることはできません。




