fix: btg link highlight text

This commit is contained in:
gosangam
2026-05-07 14:48:01 +05:30
parent cbd9c55777
commit 6e9fa4ee25
2 changed files with 88 additions and 21 deletions
+40 -7
View File
@@ -32,6 +32,30 @@ export function TextSelectionToolbar() {
// Track whether the user is interacting with our toolbar/popovers // Track whether the user is interacting with our toolbar/popovers
const interactingRef = useRef(false); const interactingRef = useRef(false);
// Save/restore selection range for iOS Safari — drawers steal focus from selection
const savedRangeRef = useRef<Range | null>(null);
const saveAndClearSelection = useCallback(() => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
sel.removeAllRanges();
}
interactingRef.current = true;
}, []);
const restoreSelection = useCallback(() => {
interactingRef.current = false;
if (savedRangeRef.current) {
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(savedRangeRef.current);
}
savedRangeRef.current = null;
}
}, []);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setSelectedText(""); setSelectedText("");
setToolbarPos(null); setToolbarPos(null);
@@ -247,7 +271,8 @@ export function TextSelectionToolbar() {
label: "Share", label: "Share",
icon: <Share2 className="size-3.5" />, icon: <Share2 className="size-3.5" />,
onClick: () => { onClick: () => {
interactingRef.current = true; if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowShare(true); setShowShare(true);
}, },
}, },
@@ -271,7 +296,8 @@ export function TextSelectionToolbar() {
label: "More", label: "More",
icon: <MoreHorizontal className="size-3.5" />, icon: <MoreHorizontal className="size-3.5" />,
onClick: () => { onClick: () => {
interactingRef.current = true; if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setMorePos({ setMorePos({
x: toolbarPos.x + 60, x: toolbarPos.x + 60,
y: toolbarPos.y, y: toolbarPos.y,
@@ -298,19 +324,24 @@ export function TextSelectionToolbar() {
selectedText={selectedText} selectedText={selectedText}
onClose={() => { onClose={() => {
setShowMore(false); setShowMore(false);
interactingRef.current = false; if (isMobile) restoreSelection();
else interactingRef.current = false;
}} }}
onBookmark={() => { onBookmark={() => {
setShowMore(false); setShowMore(false);
interactingRef.current = false; if (isMobile) restoreSelection();
else interactingRef.current = false;
}} }}
onSaveToCollection={() => { onSaveToCollection={() => {
setShowMore(false); setShowMore(false);
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true); setShowCollections(true);
}} }}
onMention={() => { onMention={() => {
setShowMore(false); setShowMore(false);
interactingRef.current = false; if (isMobile) restoreSelection();
else interactingRef.current = false;
}} }}
/> />
@@ -318,7 +349,8 @@ export function TextSelectionToolbar() {
open={showShare} open={showShare}
onClose={() => { onClose={() => {
setShowShare(false); setShowShare(false);
interactingRef.current = false; if (isMobile) restoreSelection();
else interactingRef.current = false;
}} }}
selectedText={selectedText} selectedText={selectedText}
isMobile={isMobile} isMobile={isMobile}
@@ -328,7 +360,8 @@ export function TextSelectionToolbar() {
open={showCollections} open={showCollections}
onClose={() => { onClose={() => {
setShowCollections(false); setShowCollections(false);
interactingRef.current = false; if (isMobile) restoreSelection();
else interactingRef.current = false;
}} }}
isMobile={isMobile} isMobile={isMobile}
/> />
+48 -14
View File
@@ -86,24 +86,33 @@ export function extractMatchedText(
// Build a single string with paragraph breaks // Build a single string with paragraph breaks
const fullText = plainParagraphs.join(" "); const fullText = plainParagraphs.join(" ");
const normalized = fullText.replace(/\s+/g, " "); const normalized = fullText.replace(/\s+/g, " ");
const normalizedLower = normalized.toLowerCase();
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " "); const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
let searchFrom = 0; let searchFrom = 0;
let matchStart = -1; let matchStart = -1;
while (searchFrom < normalized.length) { while (searchFrom < normalizedLower.length) {
const idx = normalized.indexOf(normalizedPrefix, searchFrom); const idx = normalizedLower.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break; if (idx === -1) break;
if (fragment.suffix) { if (fragment.suffix) {
const normalizedSuffix = fragment.suffix.replace(/\s+/g, " "); const normalizedSuffix = fragment.suffix
.replace(/\s+/g, " ")
.toLowerCase();
const expectedEnd = idx + fragment.length; const expectedEnd = idx + fragment.length;
const suffixSearchStart = Math.max( const suffixSearchStart = Math.max(
idx, idx,
expectedEnd - normalizedSuffix.length - 20, expectedEnd - normalizedSuffix.length - 20,
); );
const suffixSearchEnd = Math.min(normalized.length, expectedEnd + 20); const suffixSearchEnd = Math.min(
const searchWindow = normalized.slice(suffixSearchStart, suffixSearchEnd); normalizedLower.length,
expectedEnd + 20,
);
const searchWindow = normalizedLower.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) { if (searchWindow.includes(normalizedSuffix)) {
matchStart = idx; matchStart = idx;
break; break;
@@ -117,6 +126,7 @@ export function extractMatchedText(
if (matchStart === -1) return null; if (matchStart === -1) return null;
// Slice from the original (not lowercased) to preserve original casing
const matchEnd = Math.min(matchStart + fragment.length, normalized.length); const matchEnd = Math.min(matchStart + fragment.length, normalized.length);
const matched = normalized.slice(matchStart, matchEnd).trim(); const matched = normalized.slice(matchStart, matchEnd).trim();
@@ -165,11 +175,28 @@ export function findAndHighlightText(fragment: {
} }
}); });
// Build a concatenated string of all text content with node boundaries // Build a concatenated string of all text content with node boundaries.
// Insert a space between text nodes from different block-level parents
// so that "BACK-TO-GODHEAD" in <div>1</div> and "AN INSTRUMENT" in <div>2</div>
// become "BACK-TO-GODHEAD AN INSTRUMENT" instead of "BACK-TO-GODHEADAN INSTRUMENT".
const segments: { node: Text; start: number; end: number }[] = []; const segments: { node: Text; start: number; end: number }[] = [];
let fullText = ""; let fullText = "";
let prevParent: Element | null = null;
for (const node of allTextNodes) { for (const node of allTextNodes) {
const text = node.textContent || ""; const text = node.textContent || "";
const parent = node.parentElement;
// Add a space separator when crossing into a different block-level parent
if (
prevParent &&
parent !== prevParent &&
fullText.length > 0 &&
!fullText.endsWith(" ")
) {
fullText += " ";
}
prevParent = parent;
segments.push({ segments.push({
node, node,
start: fullText.length, start: fullText.length,
@@ -180,26 +207,32 @@ export function findAndHighlightText(fragment: {
// Normalize whitespace for matching // Normalize whitespace for matching
const normalizedFull = fullText.replace(/\s+/g, " "); const normalizedFull = fullText.replace(/\s+/g, " ");
const normalizedFullLower = normalizedFull.toLowerCase();
// Search for the prefix in the full text // Search for the prefix in the full text (case-insensitive)
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " "); const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
let searchFrom = 0; let searchFrom = 0;
let matchStart = -1; let matchStart = -1;
while (searchFrom < normalizedFull.length) { while (searchFrom < normalizedFullLower.length) {
const idx = normalizedFull.indexOf(normalizedPrefix, searchFrom); const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break; if (idx === -1) break;
// If we have a suffix, verify it appears near the expected length // If we have a suffix, verify it appears near the expected length
if (fragment.suffix) { if (fragment.suffix) {
const normalizedSuffix = fragment.suffix.replace(/\s+/g, " "); const normalizedSuffix = fragment.suffix
.replace(/\s+/g, " ")
.toLowerCase();
const expectedEnd = idx + fragment.length; const expectedEnd = idx + fragment.length;
const suffixSearchStart = Math.max( const suffixSearchStart = Math.max(
idx, idx,
expectedEnd - normalizedSuffix.length - 20, expectedEnd - normalizedSuffix.length - 20,
); );
const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20); const suffixSearchEnd = Math.min(
const searchWindow = normalizedFull.slice( normalizedFullLower.length,
expectedEnd + 20,
);
const searchWindow = normalizedFullLower.slice(
suffixSearchStart, suffixSearchStart,
suffixSearchEnd, suffixSearchEnd,
); );
@@ -217,6 +250,7 @@ export function findAndHighlightText(fragment: {
if (matchStart === -1) return false; if (matchStart === -1) return false;
// Slice from original (not lowercased) to preserve original casing in highlight
const matchEnd = Math.min( const matchEnd = Math.min(
matchStart + fragment.length, matchStart + fragment.length,
normalizedFull.length, normalizedFull.length,