From d7a582847a90896c09de9135057c8f15b92ae259 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 10:03:00 -0600 Subject: [PATCH 1/5] chore(gitignore): add .opencode/plans to .gitignore This directory is used by the OpenCode tool and should not be committed to the repository. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 26a17763bcdfcfdb5858ffd62c2e759d55a48278 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 10:30:52 -0600 Subject: [PATCH 2/5] feat(ui): wrap verse content in yv-v elements This commit introduces logic to wrap verse content within `yv-v` elements. This change facilitates easier CSS targeting for verse-specific styling, such as highlighting. The implementation handles cases where verses span multiple paragraphs by duplicating the `yv-v` wrapper as per the Bible.com pattern. It also ensures that verse labels are preserved within these new wrappers and that header elements are not erroneously included. --- packages/ui/src/components/verse.test.tsx | 119 +++++++++++++++++ packages/ui/src/components/verse.tsx | 156 +++++++++++++++++++++- packages/ui/src/styles/bible-reader.css | 1 - 3 files changed, 271 insertions(+), 5 deletions(-) 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..91b1521 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -41,6 +41,151 @@ function isExcludedNode(node: Node): boolean { return false; } +/** + * 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 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); + } + } + }); +} + +/** + * 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 Bible HTML and prepares data for footnote popovers. * @@ -287,13 +432,16 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte const parser = new DOMParser(); const doc = parser.parseFromString(processedHtml, 'text/html'); + // Wrap verse content in yv-v elements for easier CSS targeting (e.g., highlights) + wrapVerseContent(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; } }); diff --git a/packages/ui/src/styles/bible-reader.css b/packages/ui/src/styles/bible-reader.css index 8fa4ef4..d7862a9 100644 --- a/packages/ui/src/styles/bible-reader.css +++ b/packages/ui/src/styles/bible-reader.css @@ -100,7 +100,6 @@ /* Wrap verse + label + content together to prevent breaking */ & .yv-v, & .verse { - white-space: nowrap; display: inline; } From 91bb6311bba8903942a672d2e604021a014641b9 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 10:38:48 -0600 Subject: [PATCH 3/5] refactor(footnotes): use wrapped verse html for extraction The extractNotesFromHtml function has been refactored into extractNotesFromWrappedHtml. This new function assumes that verse content has already been wrapped in .yv-v[v] elements by wrapVerseContent. This change simplifies the footnote extraction process by leveraging the existing verse wrapping and making the logic more straightforward. It also ensures footnotes are correctly associated with their respective verses, even when verses span multiple paragraphs. --- packages/ui/src/components/verse.tsx | 179 +++++++++------------------ 1 file changed, 60 insertions(+), 119 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 91b1521..bc7f247 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -29,18 +29,6 @@ type ExtractedNotes = { notes: Record; }; -/** - * 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). - */ -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; -} - /** * Wraps verse content in `yv-v` elements for easier CSS targeting. * @@ -187,117 +175,76 @@ function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: strin } /** - * 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) + * 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: {} }; +function extractNotesFromWrappedHtml(doc: Document): Record { + const footnotes = Array.from(doc.querySelectorAll('.yv-n.f')); + if (!footnotes.length) return {}; - 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[], - }; - }); - - // 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 at end of last verse wrapper + const lastWrapper = verseWrappers[verseWrappers.length - 1]; + if (lastWrapper) { + const placeholder = doc.createElement('span'); + placeholder.setAttribute('data-verse-footnote', verseNum); + lastWrapper.appendChild(placeholder); } }); - 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({ @@ -417,24 +364,18 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte return { html, notes: {} }; } - let extractedNotes: Record = {}; - let processedHtml = html; - - if (extractNotes) { - const result = extractNotesFromHtml(html); - processedHtml = result.html; - extractedNotes = result.notes; - } else { - processedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); - } - - // Safely parse and modify HTML to add spaces to paragraph elements - const parser = new DOMParser(); - const doc = parser.parseFromString(processedHtml, 'text/html'); + // Parse and sanitize HTML + const doc = new DOMParser().parseFromString( + DOMPurify.sanitize(html, DOMPURIFY_CONFIG), + 'text/html', + ); - // Wrap verse content in yv-v elements for easier CSS targeting (e.g., highlights) + // Wrap verse content FIRST (enables simple footnote extraction) wrapVerseContent(doc); + // 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 verseLabels = doc.querySelectorAll('.yv-vlbl'); From e8a916bd81c2fb4e370df2bcf912c47d96bb5343 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 11:20:53 -0600 Subject: [PATCH 4/5] Fix: Place footnote placeholder correctly Insert the footnote placeholder as a sibling after the last verse wrapper, rather than as a child of the last verse wrapper. This ensures correct DOM structure and prevents potential rendering issues. --- packages/ui/src/components/verse.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index bc7f247..80f4e88 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -232,12 +232,12 @@ function extractNotesFromWrappedHtml(doc: Document): Record notes: fns.map((fn) => fn.innerHTML), }; - // Insert placeholder at end of last verse wrapper + // Insert placeholder after last verse wrapper (sibling, not child) const lastWrapper = verseWrappers[verseWrappers.length - 1]; - if (lastWrapper) { + if (lastWrapper?.parentNode) { const placeholder = doc.createElement('span'); placeholder.setAttribute('data-verse-footnote', verseNum); - lastWrapper.appendChild(placeholder); + lastWrapper.parentNode.insertBefore(placeholder, lastWrapper.nextSibling); } }); From fb6562274daf2a44e004f91161cc8a3a840e133a Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 11:54:18 -0600 Subject: [PATCH 5/5] feat(Verse): add verse selection functionality This commit introduces the ability to select individual verses within the Verse and BibleTextView components. Key changes include: - Added `selectedVerses` and `onVerseSelect` props to `VerseHtml`, `Verse`, and `BibleTextView` components. - Implemented click handlers in `HtmlWithNotes` to manage verse selection and deselection. - Added styling for selected verses in `bible-reader.css`. - Introduced a new `VerseSelection` story to demonstrate the new functionality. --- packages/ui/src/components/verse.stories.tsx | 44 ++++++++++++++++ packages/ui/src/components/verse.tsx | 53 ++++++++++++++++++++ packages/ui/src/styles/bible-reader.css | 9 +++- 3 files changed, 105 insertions(+), 1 deletion(-) 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.tsx b/packages/ui/src/components/verse.tsx index 80f4e88..b5ea7da 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -308,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()); @@ -332,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 ( <>
@@ -459,6 +500,8 @@ type VerseHtmlProps = { renderNotes?: boolean; reference?: string; theme?: 'light' | 'dark'; + selectedVerses?: number[]; + onVerseSelect?: (verses: number[]) => void; }; /** @@ -507,6 +550,8 @@ export const Verse = { renderNotes = true, reference, theme, + selectedVerses, + onVerseSelect, }: VerseHtmlProps, ref, ): ReactNode => { @@ -538,6 +583,8 @@ export const Verse = { reference={reference} fontSize={fontSize} theme={currentTheme} + selectedVerses={selectedVerses} + onVerseSelect={onVerseSelect} /> ); @@ -572,6 +619,8 @@ export type BibleTextViewProps = { showVerseNumbers?: boolean; renderNotes?: boolean; theme?: 'light' | 'dark'; + selectedVerses?: number[]; + onVerseSelect?: (verses: number[]) => void; }; /** @@ -586,6 +635,8 @@ export const BibleTextView = ({ showVerseNumbers, renderNotes, theme, + selectedVerses, + onVerseSelect, }: BibleTextViewProps): React.ReactElement => { const { passage, loading, error } = usePassage({ versionId, @@ -639,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 d7862a9..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; @@ -101,6 +100,14 @@ & .yv-v, & .verse { 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 */