|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; |
4 | 3 | import { usePassage, useTheme } from '@youversion/platform-react-hooks'; |
5 | 4 | import DOMPurify from 'isomorphic-dompurify'; |
6 | 5 | import { |
7 | 6 | forwardRef, |
8 | 7 | memo, |
| 8 | + type ReactNode, |
9 | 9 | useEffect, |
10 | 10 | useLayoutEffect, |
11 | 11 | useRef, |
12 | 12 | useState, |
13 | | - type ReactNode, |
14 | 13 | } from 'react'; |
15 | 14 | import { createPortal } from 'react-dom'; |
| 15 | +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; |
| 16 | +import { |
| 17 | + extractNotesFromWrappedHtml, |
| 18 | + LETTERS, |
| 19 | + NON_BREAKING_SPACE, |
| 20 | + type VerseNotes, |
| 21 | + wrapVerseContent, |
| 22 | +} from '@/lib/verse-html-utils'; |
16 | 23 | import { Footnote } from './icons/footnote'; |
17 | 24 |
|
18 | | -const NON_BREAKING_SPACE = '\u00A0'; |
19 | | - |
20 | | -const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; |
21 | | - |
22 | | -type VerseNotes = { |
23 | | - verseHtml: string; |
24 | | - notes: string[]; |
25 | | -}; |
26 | | - |
27 | 25 | type ExtractedNotes = { |
28 | 26 | html: string; |
29 | 27 | notes: Record<string, VerseNotes>; |
30 | 28 | }; |
31 | 29 |
|
32 | | -/** |
33 | | - * Wraps verse content in `yv-v` elements for easier CSS targeting. |
34 | | - * |
35 | | - * Transforms empty verse markers into wrapping containers. When a verse spans |
36 | | - * multiple paragraphs, creates duplicate wrappers in each paragraph (Bible.com pattern). |
37 | | - * |
38 | | - * Before: <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Text... |
39 | | - * After: <span class="yv-v" v="1"><span class="yv-vlbl">1</span>Text...</span> |
40 | | - * |
41 | | - * This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }` |
42 | | - */ |
43 | | -function wrapVerseContent(doc: Document): void { |
44 | | - const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); |
45 | | - if (!verseMarkers.length) return; |
46 | | - |
47 | | - verseMarkers.forEach((marker, markerIndex) => { |
48 | | - const verseNum = marker.getAttribute('v'); |
49 | | - if (!verseNum) return; |
50 | | - |
51 | | - const nextMarker = verseMarkers[markerIndex + 1]; |
52 | | - const markerParent = marker.parentElement; |
53 | | - if (!markerParent) return; |
54 | | - |
55 | | - const nodesToWrap: Node[] = []; |
56 | | - let currentNode: Node | null = marker.nextSibling; |
57 | | - const currentParagraph = markerParent.closest('.p, p, div.p'); |
58 | | - |
59 | | - while (currentNode) { |
60 | | - if (currentNode === nextMarker) break; |
61 | | - if (nextMarker && currentNode instanceof Element && currentNode.contains(nextMarker)) break; |
62 | | - |
63 | | - if (currentNode instanceof Element && currentNode.classList.contains('yv-h')) { |
64 | | - currentNode = currentNode.nextSibling; |
65 | | - continue; |
66 | | - } |
67 | | - |
68 | | - nodesToWrap.push(currentNode); |
69 | | - currentNode = currentNode.nextSibling; |
70 | | - } |
71 | | - |
72 | | - if (nodesToWrap.length === 0) return; |
73 | | - |
74 | | - const wrapper = doc.createElement('span'); |
75 | | - wrapper.className = 'yv-v'; |
76 | | - wrapper.setAttribute('v', verseNum); |
77 | | - |
78 | | - const firstNode = nodesToWrap[0]; |
79 | | - if (firstNode) { |
80 | | - marker.parentNode?.insertBefore(wrapper, firstNode); |
81 | | - } |
82 | | - nodesToWrap.forEach((node) => wrapper.appendChild(node)); |
83 | | - marker.remove(); |
84 | | - |
85 | | - if (!nextMarker) { |
86 | | - wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); |
87 | | - } else { |
88 | | - const nextMarkerParagraph = nextMarker.closest('.p, p, div.p'); |
89 | | - if (currentParagraph && nextMarkerParagraph && currentParagraph !== nextMarkerParagraph) { |
90 | | - wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextMarkerParagraph); |
91 | | - } |
92 | | - } |
93 | | - }); |
94 | | -} |
95 | | - |
96 | | -/** |
97 | | - * Wraps paragraphs between startParagraph and an optional endParagraph boundary. |
98 | | - * If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted. |
99 | | - */ |
100 | | -function wrapParagraphsUntilBoundary( |
101 | | - doc: Document, |
102 | | - verseNum: string, |
103 | | - startParagraph: Element | null, |
104 | | - endParagraph?: Element | null, |
105 | | -): void { |
106 | | - if (!startParagraph) return; |
107 | | - |
108 | | - let currentP: Element | null = startParagraph.nextElementSibling; |
109 | | - |
110 | | - while (currentP && currentP !== endParagraph) { |
111 | | - // Skip heading elements - these are structural, not verse content |
112 | | - // See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift |
113 | | - const isHeading = |
114 | | - currentP.classList.contains('yv-h') || |
115 | | - currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r'); |
116 | | - if (isHeading) { |
117 | | - currentP = currentP.nextElementSibling; |
118 | | - continue; |
119 | | - } |
120 | | - |
121 | | - if (currentP.querySelector('.yv-v[v]')) break; |
122 | | - |
123 | | - if ( |
124 | | - currentP.classList.contains('p') || |
125 | | - currentP.tagName === 'P' || |
126 | | - (currentP.tagName === 'DIV' && currentP.classList.contains('p')) |
127 | | - ) { |
128 | | - wrapParagraphContent(doc, currentP, verseNum); |
129 | | - } |
130 | | - |
131 | | - currentP = currentP.nextElementSibling; |
132 | | - } |
133 | | -} |
134 | | - |
135 | | -/** |
136 | | - * Wraps all content in a paragraph with a verse span. |
137 | | - */ |
138 | | -function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { |
139 | | - const children = Array.from(paragraph.childNodes); |
140 | | - if (children.length === 0) return; |
141 | | - |
142 | | - const wrapper = doc.createElement('span'); |
143 | | - wrapper.className = 'yv-v'; |
144 | | - wrapper.setAttribute('v', verseNum); |
145 | | - |
146 | | - const firstChild = children[0]; |
147 | | - if (firstChild) { |
148 | | - paragraph.insertBefore(wrapper, firstChild); |
149 | | - } |
150 | | - children.forEach((child) => wrapper.appendChild(child)); |
151 | | -} |
152 | | - |
153 | | -/** |
154 | | - * Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers. |
155 | | - * |
156 | | - * This function assumes verses are already wrapped in `.yv-v[v]` elements (by wrapVerseContent). |
157 | | - * It uses `.closest('.yv-v[v]')` to find which verse each footnote belongs to. |
158 | | - * |
159 | | - * @returns Notes data for popovers, keyed by verse number |
160 | | - */ |
161 | | -function extractNotesFromWrappedHtml(doc: Document): Record<string, VerseNotes> { |
162 | | - const footnotes = Array.from(doc.querySelectorAll('.yv-n.f')); |
163 | | - if (!footnotes.length) return {}; |
164 | | - |
165 | | - // Group footnotes by verse number using closest wrapper |
166 | | - const footnotesByVerse = new Map<string, Element[]>(); |
167 | | - footnotes.forEach((fn) => { |
168 | | - const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v'); |
169 | | - if (verseNum) { |
170 | | - let arr = footnotesByVerse.get(verseNum); |
171 | | - if (!arr) { |
172 | | - arr = []; |
173 | | - footnotesByVerse.set(verseNum, arr); |
174 | | - } |
175 | | - arr.push(fn); |
176 | | - } |
177 | | - }); |
178 | | - |
179 | | - const notes: Record<string, VerseNotes> = {}; |
180 | | - |
181 | | - footnotesByVerse.forEach((fns, verseNum) => { |
182 | | - // Find all wrappers for this verse (could be multiple if verse spans paragraphs) |
183 | | - const verseWrappers = Array.from(doc.querySelectorAll(`.yv-v[v="${verseNum}"]`)); |
184 | | - |
185 | | - // Build verse HTML with A/B/C markers for popover display |
186 | | - let verseHtml = ''; |
187 | | - let noteIdx = 0; |
188 | | - |
189 | | - verseWrappers.forEach((wrapper, wrapperIdx) => { |
190 | | - if (wrapperIdx > 0) verseHtml += ' '; |
191 | | - |
192 | | - const walker = doc.createTreeWalker(wrapper, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); |
193 | | - while (walker.nextNode()) { |
194 | | - const node = walker.currentNode; |
195 | | - if (node instanceof Element) { |
196 | | - if (node.classList.contains('yv-n') && node.classList.contains('f')) { |
197 | | - verseHtml += `<sup class="yv:text-muted-foreground">${LETTERS[noteIdx++] || noteIdx}</sup>`; |
198 | | - } |
199 | | - } else if (node.nodeType === Node.TEXT_NODE) { |
200 | | - const parent = node.parentElement; |
201 | | - if (parent?.closest('.yv-n.f') || parent?.closest('.yv-h')) continue; |
202 | | - if (parent?.classList.contains('yv-vlbl')) continue; |
203 | | - verseHtml += node.textContent || ''; |
204 | | - } |
205 | | - } |
206 | | - }); |
207 | | - |
208 | | - notes[verseNum] = { |
209 | | - verseHtml, |
210 | | - notes: fns.map((fn) => fn.innerHTML), |
211 | | - }; |
212 | | - |
213 | | - // Insert placeholder at end of last verse wrapper |
214 | | - const lastWrapper = verseWrappers[verseWrappers.length - 1]; |
215 | | - const placeholder = doc.createElement('span'); |
216 | | - placeholder.setAttribute('data-verse-footnote', verseNum); |
217 | | - lastWrapper.appendChild(placeholder); |
218 | | - }); |
219 | | - |
220 | | - // Remove all footnotes from DOM |
221 | | - footnotes.forEach((fn) => fn.remove()); |
222 | | - |
223 | | - return notes; |
224 | | -} |
225 | | - |
226 | 30 | const VerseFootnoteButton = memo(function VerseFootnoteButton({ |
227 | 31 | verseNum, |
228 | 32 | verseNotes, |
|
0 commit comments