diff --git a/components/text-selection-toolbar.tsx b/components/text-selection-toolbar.tsx index 1c9f4906..1af4ef7c 100644 --- a/components/text-selection-toolbar.tsx +++ b/components/text-selection-toolbar.tsx @@ -32,6 +32,30 @@ export function TextSelectionToolbar() { // Track whether the user is interacting with our toolbar/popovers const interactingRef = useRef(false); + // Save/restore selection range for iOS Safari — drawers steal focus from selection + const savedRangeRef = useRef(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(() => { setSelectedText(""); setToolbarPos(null); @@ -247,7 +271,8 @@ export function TextSelectionToolbar() { label: "Share", icon: , onClick: () => { - interactingRef.current = true; + if (isMobile) saveAndClearSelection(); + else interactingRef.current = true; setShowShare(true); }, }, @@ -271,7 +296,8 @@ export function TextSelectionToolbar() { label: "More", icon: , onClick: () => { - interactingRef.current = true; + if (isMobile) saveAndClearSelection(); + else interactingRef.current = true; setMorePos({ x: toolbarPos.x + 60, y: toolbarPos.y, @@ -298,19 +324,24 @@ export function TextSelectionToolbar() { selectedText={selectedText} onClose={() => { setShowMore(false); - interactingRef.current = false; + if (isMobile) restoreSelection(); + else interactingRef.current = false; }} onBookmark={() => { setShowMore(false); - interactingRef.current = false; + if (isMobile) restoreSelection(); + else interactingRef.current = false; }} onSaveToCollection={() => { setShowMore(false); + if (isMobile) saveAndClearSelection(); + else interactingRef.current = true; setShowCollections(true); }} onMention={() => { setShowMore(false); - interactingRef.current = false; + if (isMobile) restoreSelection(); + else interactingRef.current = false; }} /> @@ -318,7 +349,8 @@ export function TextSelectionToolbar() { open={showShare} onClose={() => { setShowShare(false); - interactingRef.current = false; + if (isMobile) restoreSelection(); + else interactingRef.current = false; }} selectedText={selectedText} isMobile={isMobile} @@ -328,7 +360,8 @@ export function TextSelectionToolbar() { open={showCollections} onClose={() => { setShowCollections(false); - interactingRef.current = false; + if (isMobile) restoreSelection(); + else interactingRef.current = false; }} isMobile={isMobile} /> diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts index 983d0566..18e7687e 100644 --- a/lib/text-highlight.ts +++ b/lib/text-highlight.ts @@ -86,24 +86,33 @@ export function extractMatchedText( // Build a single string with paragraph breaks const fullText = plainParagraphs.join(" "); 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 matchStart = -1; - while (searchFrom < normalized.length) { - const idx = normalized.indexOf(normalizedPrefix, searchFrom); + while (searchFrom < normalizedLower.length) { + const idx = normalizedLower.indexOf(normalizedPrefix, searchFrom); if (idx === -1) break; if (fragment.suffix) { - const normalizedSuffix = fragment.suffix.replace(/\s+/g, " "); + const normalizedSuffix = fragment.suffix + .replace(/\s+/g, " ") + .toLowerCase(); const expectedEnd = idx + fragment.length; const suffixSearchStart = Math.max( idx, expectedEnd - normalizedSuffix.length - 20, ); - const suffixSearchEnd = Math.min(normalized.length, expectedEnd + 20); - const searchWindow = normalized.slice(suffixSearchStart, suffixSearchEnd); + const suffixSearchEnd = Math.min( + normalizedLower.length, + expectedEnd + 20, + ); + const searchWindow = normalizedLower.slice( + suffixSearchStart, + suffixSearchEnd, + ); if (searchWindow.includes(normalizedSuffix)) { matchStart = idx; break; @@ -117,6 +126,7 @@ export function extractMatchedText( 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 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
1
and "AN INSTRUMENT" in
2
+ // become "BACK-TO-GODHEAD AN INSTRUMENT" instead of "BACK-TO-GODHEADAN INSTRUMENT". const segments: { node: Text; start: number; end: number }[] = []; let fullText = ""; + let prevParent: Element | null = null; for (const node of allTextNodes) { 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({ node, start: fullText.length, @@ -180,26 +207,32 @@ export function findAndHighlightText(fragment: { // Normalize whitespace for matching const normalizedFull = fullText.replace(/\s+/g, " "); + const normalizedFullLower = normalizedFull.toLowerCase(); - // Search for the prefix in the full text - const normalizedPrefix = fragment.prefix.replace(/\s+/g, " "); + // Search for the prefix in the full text (case-insensitive) + const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase(); let searchFrom = 0; let matchStart = -1; - while (searchFrom < normalizedFull.length) { - const idx = normalizedFull.indexOf(normalizedPrefix, searchFrom); + while (searchFrom < normalizedFullLower.length) { + const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom); if (idx === -1) break; // If we have a suffix, verify it appears near the expected length if (fragment.suffix) { - const normalizedSuffix = fragment.suffix.replace(/\s+/g, " "); + const normalizedSuffix = fragment.suffix + .replace(/\s+/g, " ") + .toLowerCase(); const expectedEnd = idx + fragment.length; const suffixSearchStart = Math.max( idx, expectedEnd - normalizedSuffix.length - 20, ); - const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20); - const searchWindow = normalizedFull.slice( + const suffixSearchEnd = Math.min( + normalizedFullLower.length, + expectedEnd + 20, + ); + const searchWindow = normalizedFullLower.slice( suffixSearchStart, suffixSearchEnd, ); @@ -217,6 +250,7 @@ export function findAndHighlightText(fragment: { if (matchStart === -1) return false; + // Slice from original (not lowercased) to preserve original casing in highlight const matchEnd = Math.min( matchStart + fragment.length, normalizedFull.length,