fix: btg link highlight text
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user