diff --git a/.gitignore b/.gitignore index 24df55d..f52839a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,5 @@ storybook-static !.yarn/sdks !.yarn/versions .yarn/install-state.gz + +.opencode/plans diff --git a/packages/ui/src/components/verse.stories.tsx b/packages/ui/src/components/verse.stories.tsx index 6b36c95..33a946d 100644 --- a/packages/ui/src/components/verse.stories.tsx +++ b/packages/ui/src/components/verse.stories.tsx @@ -344,3 +344,47 @@ export const FootnotePopoverThemeDark: Story = { }); }, }; + +function VerseSelectionDemo(props) { + const [selectedVerses, setSelectedVerses] = React.useState([]); + + return ( +
+
+ Selected: {selectedVerses.length > 0 ? selectedVerses.join(', ') : 'None'} + {selectedVerses.length > 0 && ( + + )} +
+ +
+ +
+
+ ); +} + +export const VerseSelection: Story = { + args: { + reference: 'JHN.1', + versionId: 111, + renderNotes: true, + }, + render: (props) => , +}; diff --git a/packages/ui/src/components/verse.test.tsx b/packages/ui/src/components/verse.test.tsx index ffc191b..c2746b7 100644 --- a/packages/ui/src/components/verse.test.tsx +++ b/packages/ui/src/components/verse.test.tsx @@ -319,6 +319,125 @@ describe('Verse.Html - Footnotes', () => { }); }); +describe('Verse.Html - Verse Wrapping', () => { + it('should wrap verse content in yv-v elements', async () => { + const html = ` +
+ 1In the beginning was the Word. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verseWrapper = container.querySelector('.yv-v[v="1"]'); + expect(verseWrapper).not.toBeNull(); + expect(verseWrapper?.textContent).toContain('In the beginning was the Word'); + }); + }); + + it('should wrap multiple verses in same paragraph', async () => { + const html = ` +
+ 1First verse. + 2Second verse. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verse1 = container.querySelector('.yv-v[v="1"]'); + const verse2 = container.querySelector('.yv-v[v="2"]'); + + expect(verse1).not.toBeNull(); + expect(verse2).not.toBeNull(); + expect(verse1?.textContent).toContain('First verse'); + expect(verse2?.textContent).toContain('Second verse'); + }); + }); + + it('should duplicate yv-v wrapper when verse spans multiple paragraphs', async () => { + const html = ` +
+ 39"Come," he replied. +
+
So they went and saw where he was staying.
+
+ 40Andrew was one of the two. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verse39Wrappers = container.querySelectorAll('.yv-v[v="39"]'); + expect(verse39Wrappers.length).toBe(2); + + expect(verse39Wrappers[0]?.textContent).toContain('Come'); + expect(verse39Wrappers[1]?.textContent).toContain('So they went'); + }); + }); + + it('should enable CSS selection of individual verses', async () => { + const html = ` +
+ 1First verse text. + 2Second verse text. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verse1 = container.querySelector('.yv-v[v="1"]'); + const verse2 = container.querySelector('.yv-v[v="2"]'); + + expect(verse1).not.toBeNull(); + expect(verse2).not.toBeNull(); + expect(verse1).not.toBe(verse2); + }); + }); + + it('should preserve verse label inside wrapper', async () => { + const html = ` +
+ 5The light shines. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verseWrapper = container.querySelector('.yv-v[v="5"]'); + const label = verseWrapper?.querySelector('.yv-vlbl'); + + expect(label).not.toBeNull(); + expect(label?.textContent).toContain('5'); + }); + }); + + it('should not wrap header elements in verse spans', async () => { + const html = ` +
+ 42And he brought him to Jesus. +
+
Jesus Calls Philip
+
+ 43The next day Jesus decided to leave. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const header = container.querySelector('.yv-h'); + expect(header).not.toBeNull(); + expect(header?.closest('.yv-v')).toBeNull(); + }); + }); +}); + describe('Verse.Text', () => { it('should render verse with number and text (default size)', () => { const { container } = render(); diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 4b33257..b5ea7da 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -30,129 +30,221 @@ type ExtractedNotes = { }; /** - * Checks if a node should be excluded from verse text reconstruction. - * Excludes: verse markers (.yv-v), verse labels (.yv-vlbl), headers (.yv-h), and footnotes (.yv-n). + * Wraps verse content in `yv-v` elements for easier CSS targeting. + * + * Transforms empty verse markers into wrapping containers. When a verse spans + * multiple paragraphs, creates duplicate wrappers in each paragraph (Bible.com pattern). + * + * Before: 1Text... + * After: 1Text... + * + * This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }` */ -function isExcludedNode(node: Node): boolean { - if (!(node instanceof Element)) return false; - if (node.classList.contains('yv-v') || node.classList.contains('yv-vlbl')) return true; - if (node.classList.contains('yv-h') || node.closest('.yv-h')) return true; - if (node.classList.contains('yv-n') || node.closest('.yv-n')) return true; - return false; +function wrapVerseContent(doc: Document): void { + const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); + if (!verseMarkers.length) return; + + verseMarkers.forEach((marker, markerIndex) => { + const verseNum = marker.getAttribute('v'); + if (!verseNum) return; + + const nextMarker = verseMarkers[markerIndex + 1]; + const markerParent = marker.parentElement; + if (!markerParent) return; + + const nodesToWrap: Node[] = []; + let currentNode: Node | null = marker.nextSibling; + const currentParagraph = markerParent.closest('.p, p, div.p'); + + while (currentNode) { + if (currentNode === nextMarker) break; + if (nextMarker && currentNode instanceof Element && currentNode.contains(nextMarker)) break; + + if (currentNode instanceof Element && currentNode.classList.contains('yv-h')) { + currentNode = currentNode.nextSibling; + continue; + } + + nodesToWrap.push(currentNode); + currentNode = currentNode.nextSibling; + } + + if (nodesToWrap.length === 0) return; + + const wrapper = doc.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstNode = nodesToWrap[0]; + if (firstNode) { + marker.parentNode?.insertBefore(wrapper, firstNode); + } + nodesToWrap.forEach((node) => wrapper.appendChild(node)); + marker.remove(); + + if (!nextMarker) { + wrapRemainingParagraphs(doc, verseNum, currentParagraph); + } else { + const nextMarkerParagraph = nextMarker.closest('.p, p, div.p'); + if (currentParagraph && nextMarkerParagraph && currentParagraph !== nextMarkerParagraph) { + wrapIntermediateParagraphs(doc, verseNum, currentParagraph, nextMarkerParagraph); + } + } + }); } /** - * Extracts footnotes from Bible HTML and prepares data for footnote popovers. - * - * This function does three things: - * 1. Identifies verse boundaries using `.yv-v[v]` markers (verses can span multiple paragraphs) - * 2. For each verse with footnotes, builds a plain-text version with A/B/C markers for the popover - * 3. Inserts placeholder spans at the end of each verse (where the footnote icon will render) + * Wraps content in paragraphs between the current verse wrapper and the next verse marker. + */ +function wrapIntermediateParagraphs( + doc: Document, + verseNum: string, + startParagraph: Element, + endParagraph: Element, +): void { + let currentP: Element | null = startParagraph.nextElementSibling; + + while (currentP && currentP !== endParagraph) { + if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { + currentP = currentP.nextElementSibling; + continue; + } + + if ( + currentP.classList.contains('p') || + currentP.tagName === 'P' || + (currentP.tagName === 'DIV' && currentP.classList.contains('p')) + ) { + if (!currentP.querySelector('.yv-v[v]')) { + wrapParagraphContent(doc, currentP, verseNum); + } + } + + currentP = currentP.nextElementSibling; + } +} + +/** + * Wraps remaining paragraphs after the last verse marker. + */ +function wrapRemainingParagraphs( + doc: Document, + verseNum: string, + startParagraph: Element | null, +): void { + if (!startParagraph) return; + + let currentP: Element | null = startParagraph.nextElementSibling; + + while (currentP) { + if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { + currentP = currentP.nextElementSibling; + continue; + } + + if (currentP.querySelector('.yv-v[v]')) break; + + if ( + currentP.classList.contains('p') || + currentP.tagName === 'P' || + (currentP.tagName === 'DIV' && currentP.classList.contains('p')) + ) { + wrapParagraphContent(doc, currentP, verseNum); + } + + currentP = currentP.nextElementSibling; + } +} + +/** + * Wraps all content in a paragraph with a verse span. + */ +function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { + const children = Array.from(paragraph.childNodes); + if (children.length === 0) return; + + const wrapper = doc.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstChild = children[0]; + if (firstChild) { + paragraph.insertBefore(wrapper, firstChild); + } + children.forEach((child) => wrapper.appendChild(child)); +} + +/** + * Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers. * - * The challenge: verses don't respect paragraph boundaries. A verse starts at `.yv-v[v="X"]` - * and ends at the next `.yv-v[v]` marker, potentially spanning multiple `
` elements. - * We use a TreeWalker to flatten the DOM into document order, then use index ranges to define verses. + * This function assumes verses are already wrapped in `.yv-v[v]` elements (by wrapVerseContent). + * It uses `.closest('.yv-v[v]')` to find which verse each footnote belongs to. * - * @returns Modified HTML with footnotes removed and placeholders inserted, plus notes data for popovers + * @returns Notes data for popovers, keyed by verse number */ -function extractNotesFromHtml(html: string): ExtractedNotes { - if (typeof window === 'undefined') return { html, notes: {} }; - - const doc = new DOMParser().parseFromString( - DOMPurify.sanitize(html, DOMPURIFY_CONFIG), - 'text/html', - ); - const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); - if (!verseMarkers.length) return { html: doc.body.innerHTML, notes: {} }; - - // Flatten DOM into document order so we can define verse boundaries by index ranges - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); - const allNodes: Node[] = []; - do { - allNodes.push(walker.currentNode); - } while (walker.nextNode()); - - const nodeIndex = new Map(allNodes.map((n, i) => [n, i])); - const footnotes = doc.querySelectorAll('.yv-n.f'); - - // Define verse boundaries: each verse spans from its marker to the next marker (or end of content) - const verses = verseMarkers.map((marker, i) => { - const nextMarker = verseMarkers[i + 1]; - return { - num: marker.getAttribute('v') || '0', - start: nodeIndex.get(marker) ?? 0, - end: nextMarker ? (nodeIndex.get(nextMarker) ?? allNodes.length) : allNodes.length, - fns: [] as Element[], - }; - }); +function extractNotesFromWrappedHtml(doc: Document): Record { + const footnotes = Array.from(doc.querySelectorAll('.yv-n.f')); + if (!footnotes.length) return {}; - // Assign each footnote to its containing verse (find verse whose range contains the footnote) + // Group footnotes by verse number using closest wrapper + const footnotesByVerse = new Map(); footnotes.forEach((fn) => { - const idx = nodeIndex.get(fn); - if (idx !== undefined) { - const verse = [...verses].reverse().find((v) => idx > v.start); - if (verse) verse.fns.push(fn); + const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v'); + if (verseNum) { + if (!footnotesByVerse.has(verseNum)) { + footnotesByVerse.set(verseNum, []); + } + footnotesByVerse.get(verseNum)!.push(fn); } }); - const withNotes = verses.filter((v) => v.fns.length > 0); - const notes: Record = {}; - withNotes.forEach((verse) => { - // Build plain-text verse content for popover, replacing footnotes with A/B/C markers - let text = ''; + + footnotesByVerse.forEach((fns, verseNum) => { + // Find all wrappers for this verse (could be multiple if verse spans paragraphs) + const verseWrappers = Array.from(doc.querySelectorAll(`.yv-v[v="${verseNum}"]`)); + + // Build verse HTML with A/B/C markers for popover display + let verseHtml = ''; let noteIdx = 0; - let lastP: Element | null = null; - for (let i = verse.start; i < verse.end; i++) { - const node = allNodes[i]; - if (!node) continue; - const parent = node.parentNode as Element | null; + verseWrappers.forEach((wrapper, wrapperIdx) => { + if (wrapperIdx > 0) verseHtml += ' '; - if (node instanceof Element) { - if (node.classList.contains('yv-h') || node.closest('.yv-h')) continue; - if (node.classList.contains('yv-n') && node.classList.contains('f')) { - text += `${LETTERS[noteIdx++] || noteIdx}`; + const walker = doc.createTreeWalker(wrapper, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + if (node instanceof Element) { + if (node.classList.contains('yv-n') && node.classList.contains('f')) { + verseHtml += `${LETTERS[noteIdx++] || noteIdx}`; + } + } else if (node.nodeType === Node.TEXT_NODE) { + const parent = node.parentElement; + if (parent?.closest('.yv-n.f') || parent?.closest('.yv-h')) continue; + if (parent?.classList.contains('yv-vlbl')) continue; + verseHtml += node.textContent || ''; } - } else if (node.nodeType === Node.TEXT_NODE && parent) { - if (parent.closest('.yv-h') || parent.closest('.yv-n.f')) continue; - if (parent.classList.contains('yv-v') || parent.classList.contains('yv-vlbl')) continue; - // Add space when transitioning between paragraphs (verses can span multiple

elements) - const curP = parent.closest('.p, p, div.p'); - if (lastP && curP && lastP !== curP) text += ' '; - text += node.textContent || ''; - if (curP) lastP = curP; } - } + }); - notes[verse.num] = { verseHtml: text, notes: verse.fns.map((fn) => fn.innerHTML) }; - - // Insert placeholder at end of verse content (walk backwards to find last text node) - for (let i = verse.end - 1; i > verse.start; i--) { - const node = allNodes[i]; - if (!node) continue; - const parent = node.parentNode as Element | null; - if ( - node.nodeType === Node.TEXT_NODE && - node.textContent?.trim() && - parent && - !isExcludedNode(parent) && - !parent.closest('.yv-n') && - !parent.closest('.yv-h') - ) { - const placeholder = doc.createElement('span'); - placeholder.setAttribute('data-verse-footnote', verse.num); - parent.insertBefore(placeholder, node.nextSibling); - break; - } + notes[verseNum] = { + verseHtml, + notes: fns.map((fn) => fn.innerHTML), + }; + + // Insert placeholder after last verse wrapper (sibling, not child) + const lastWrapper = verseWrappers[verseWrappers.length - 1]; + if (lastWrapper?.parentNode) { + const placeholder = doc.createElement('span'); + placeholder.setAttribute('data-verse-footnote', verseNum); + lastWrapper.parentNode.insertBefore(placeholder, lastWrapper.nextSibling); } }); - footnotes.forEach((fn) => { - fn.remove(); - }); + // Remove all footnotes from DOM + footnotes.forEach((fn) => fn.remove()); - return { html: doc.body.innerHTML, notes }; + return notes; } const VerseFootnoteButton = memo(function VerseFootnoteButton({ @@ -216,12 +308,16 @@ function HtmlWithNotes({ reference, fontSize, theme, + selectedVerses = [], + onVerseSelect, }: { html: string; notes: Record; reference?: string; fontSize?: number; theme?: 'light' | 'dark'; + selectedVerses?: number[]; + onVerseSelect?: (verses: number[]) => void; }) { const contentRef = useRef(null); const [placeholders, setPlaceholders] = useState>(new Map()); @@ -240,6 +336,43 @@ function HtmlWithNotes({ setPlaceholders(map); }, [html, notes]); + useLayoutEffect(() => { + if (!contentRef.current) return; + + const verseElements = contentRef.current.querySelectorAll('.yv-v[v]'); + verseElements.forEach((el) => { + const verseNum = parseInt(el.getAttribute('v') || '0', 10); + if (selectedVerses.includes(verseNum)) { + el.classList.add('yv-v-selected'); + } else { + el.classList.remove('yv-v-selected'); + } + }); + }, [selectedVerses]); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element || !onVerseSelect) return; + + const handleClick = (e: Event) => { + const target = e.target as HTMLElement; + const verseEl = target.closest('.yv-v[v]'); + if (!verseEl) return; + + const verseNum = parseInt(verseEl.getAttribute('v') || '0', 10); + if (verseNum === 0) return; + + const newSelected = selectedVerses.includes(verseNum) + ? selectedVerses.filter((v) => v !== verseNum) + : [...selectedVerses, verseNum].sort((a, b) => a - b); + + onVerseSelect(newSelected); + }; + + element.addEventListener('click', handleClick); + return () => element.removeEventListener('click', handleClick); + }, [selectedVerses, onVerseSelect]); + return ( <>

@@ -272,28 +405,25 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte return { html, notes: {} }; } - let extractedNotes: Record = {}; - let processedHtml = html; + // Parse and sanitize HTML + const doc = new DOMParser().parseFromString( + DOMPurify.sanitize(html, DOMPURIFY_CONFIG), + 'text/html', + ); - if (extractNotes) { - const result = extractNotesFromHtml(html); - processedHtml = result.html; - extractedNotes = result.notes; - } else { - processedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); - } + // Wrap verse content FIRST (enables simple footnote extraction) + wrapVerseContent(doc); - // Safely parse and modify HTML to add spaces to paragraph elements - const parser = new DOMParser(); - const doc = parser.parseFromString(processedHtml, 'text/html'); + // Extract footnotes using the wrapped verse structure + const extractedNotes = extractNotes ? extractNotesFromWrappedHtml(doc) : {}; // Adds non-breaking space to the end of verse labels for better copying and pasting // (i.e. "3For God so loved..." to "3 For God so loved...") - const paragraphs = doc.querySelectorAll('.yv-vlbl'); - paragraphs.forEach((p) => { - const text = p.textContent || ''; + const verseLabels = doc.querySelectorAll('.yv-vlbl'); + verseLabels.forEach((label) => { + const text = label.textContent || ''; if (!text.endsWith(NON_BREAKING_SPACE)) { - p.textContent = text + NON_BREAKING_SPACE; + label.textContent = text + NON_BREAKING_SPACE; } }); @@ -370,6 +500,8 @@ type VerseHtmlProps = { renderNotes?: boolean; reference?: string; theme?: 'light' | 'dark'; + selectedVerses?: number[]; + onVerseSelect?: (verses: number[]) => void; }; /** @@ -418,6 +550,8 @@ export const Verse = { renderNotes = true, reference, theme, + selectedVerses, + onVerseSelect, }: VerseHtmlProps, ref, ): ReactNode => { @@ -449,6 +583,8 @@ export const Verse = { reference={reference} fontSize={fontSize} theme={currentTheme} + selectedVerses={selectedVerses} + onVerseSelect={onVerseSelect} /> ); @@ -483,6 +619,8 @@ export type BibleTextViewProps = { showVerseNumbers?: boolean; renderNotes?: boolean; theme?: 'light' | 'dark'; + selectedVerses?: number[]; + onVerseSelect?: (verses: number[]) => void; }; /** @@ -497,6 +635,8 @@ export const BibleTextView = ({ showVerseNumbers, renderNotes, theme, + selectedVerses, + onVerseSelect, }: BibleTextViewProps): React.ReactElement => { const { passage, loading, error } = usePassage({ versionId, @@ -550,6 +690,8 @@ export const BibleTextView = ({ renderNotes={renderNotes} reference={passage?.reference} theme={currentTheme} + selectedVerses={selectedVerses} + onVerseSelect={onVerseSelect} />
); diff --git a/packages/ui/src/styles/bible-reader.css b/packages/ui/src/styles/bible-reader.css index 8fa4ef4..29816a0 100644 --- a/packages/ui/src/styles/bible-reader.css +++ b/packages/ui/src/styles/bible-reader.css @@ -59,7 +59,6 @@ & .fv, & .yv-vlbl { display: inline; - padding-right: 0.3em; font-size: 0.5em; color: var(--yv-muted-foreground); line-height: 1em; @@ -100,8 +99,15 @@ /* Wrap verse + label + content together to prevent breaking */ & .yv-v, & .verse { - white-space: nowrap; display: inline; + cursor: pointer; + } + + /* Selected verse styling */ + & .yv-v.yv-v-selected { + text-decoration: underline; + text-decoration-color: var(--yv-border); + text-underline-offset: 0.33em; } /* Major titles */