diff --git a/.changeset/great-showers-fry.md b/.changeset/great-showers-fry.md
new file mode 100644
index 00000000..8231418c
--- /dev/null
+++ b/.changeset/great-showers-fry.md
@@ -0,0 +1,14 @@
+---
+'@youversion/platform-react-ui': patch
+'@youversion/platform-core': patch
+'@youversion/platform-react-hooks': patch
+---
+
+Refactor verse footnote extraction and rendering for clarity and correctness
+
+- Replace TreeWalker-based footnote extraction with clone-and-transform approach
+- Move HTML transformation pipeline into `verse-html-utils.ts` as `transformBibleHtml`
+- Fix space insertion between element siblings when footnotes are removed
+- Fix footnote marker/label mismatch for verses with >26 footnotes
+- Simplify `BibleTextHtml` hooks and use React `onClick` instead of manual event listeners
+- Use `useMemo` for synchronous HTML transformation instead of `useEffect` + `useState`
diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx
index 4a581694..26ffdbf7 100644
--- a/packages/ui/src/components/bible-reader.stories.tsx
+++ b/packages/ui/src/components/bible-reader.stories.tsx
@@ -271,7 +271,7 @@ export const FootnotesPersistAfterFontSizeChange: Story = {
await waitFor(
async () => {
const footnoteButtons = getFootnoteButtons();
- await expect(footnoteButtons.length).toBe(9);
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
diff --git a/packages/ui/src/components/verse.stories.tsx b/packages/ui/src/components/verse.stories.tsx
index 09255b53..5061e229 100644
--- a/packages/ui/src/components/verse.stories.tsx
+++ b/packages/ui/src/components/verse.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import React from 'react';
-import { type BibleTextViewProps, BibleTextView } from './verse';
+import { type BibleTextViewProps, BibleTextView, Verse } from './verse';
import { Button } from './ui/button';
import { XIcon } from '@/components/icons/x';
@@ -18,6 +18,15 @@ type DebouncedBibleTextViewProps = {
debounceMs?: number;
};
+const MULTIPLE_FOOTNOTE_SINGLE_VERSE_HTML = `
+
+ 51He then added,
+ "Very truly I tell you,1:51 The Greek is plural.
+ you1:51 The Greek is plural.
+ will see heaven open."
+
+`;
+
function DebouncedBibleTextView({
reference,
versionId,
@@ -210,14 +219,15 @@ export const FootnoteInteraction: Story = {
play: async ({ canvasElement }) => {
await waitFor(
async () => {
- const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
- await expect(footnoteButton).toBeInTheDocument();
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote] button');
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
- const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
- await userEvent.click(footnoteButton!);
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote] button');
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
+ await userEvent.click(footnoteButtons[0]!);
await waitFor(async () => {
await expect(await screen.findByText('Footnotes')).toBeInTheDocument();
@@ -234,6 +244,52 @@ export const FootnoteInteraction: Story = {
},
};
+export const MultipleFootnotesInSingleVerse: Story = {
+ args: {
+ reference: 'JHN.1.51',
+ versionId: 111,
+ renderNotes: true,
+ },
+ tags: ['integration'],
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await waitFor(
+ async () => {
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote="51"] button');
+ await expect(footnoteButtons.length).toBe(2);
+ },
+ { timeout: 5000 },
+ );
+
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote="51"] button');
+ await expect(footnoteButtons.length).toBe(2);
+
+ for (const button of footnoteButtons) {
+ await expect(button.closest('.yv-v[v="51"]')).toBeInTheDocument();
+ }
+
+ const footnoteAnchors = canvasElement.querySelectorAll('[data-verse-footnote="51"]');
+ await expect(footnoteAnchors.length).toBe(2);
+
+ const firstAnchor = footnoteAnchors[0];
+ const secondAnchor = footnoteAnchors[1];
+
+ await expect(firstAnchor?.previousElementSibling?.textContent ?? '').toMatch(
+ /very truly i tell you,/i,
+ );
+ await expect(firstAnchor?.nextElementSibling?.textContent ?? '').toMatch(/^you$/i);
+
+ await expect(secondAnchor?.previousElementSibling?.textContent ?? '').toMatch(/^you$/i);
+ await expect(secondAnchor?.nextElementSibling?.textContent ?? '').toMatch(
+ /will see heaven open/i,
+ );
+ },
+};
+
export const DarkMode: Story = {
args: {
reference: 'JHN.3.16',
@@ -265,16 +321,17 @@ export const FootnotePopoverThemeLight: Story = {
await waitFor(
async () => {
- const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
- await expect(footnoteButton).toBeInTheDocument();
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote] button');
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
- const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
- await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument();
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote] button');
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
+ await expect(footnoteButtons[0]?.closest('[data-yv-theme="light"]')).toBeInTheDocument();
- await userEvent.click(footnoteButton!);
+ await userEvent.click(footnoteButtons[0]!);
await waitFor(async () => {
const popover = document.querySelector('[data-slot="popover-content"]');
@@ -307,16 +364,17 @@ export const FootnotePopoverThemeDark: Story = {
await waitFor(
async () => {
- const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
- await expect(footnoteButton).toBeInTheDocument();
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote] button');
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
- const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
- await expect(footnoteButton?.closest('[data-yv-theme="dark"]')).toBeInTheDocument();
+ const footnoteButtons = canvasElement.querySelectorAll('[data-verse-footnote] button');
+ await expect(footnoteButtons.length).toBeGreaterThan(0);
+ await expect(footnoteButtons[0]?.closest('[data-yv-theme="dark"]')).toBeInTheDocument();
- await userEvent.click(footnoteButton!);
+ await userEvent.click(footnoteButtons[0]!);
await waitFor(async () => {
const popover = document.querySelector('[data-slot="popover-content"]');
diff --git a/packages/ui/src/components/verse.test.tsx b/packages/ui/src/components/verse.test.tsx
index ad90f26f..4581db45 100644
--- a/packages/ui/src/components/verse.test.tsx
+++ b/packages/ui/src/components/verse.test.tsx
@@ -297,17 +297,17 @@ describe('Verse.Html - Footnotes', () => {
const { container } = render();
await waitFor(() => {
- const placeholder = container.querySelector('[data-verse-footnote="51"]');
- expect(placeholder).not.toBeNull();
+ const placeholders = container.querySelectorAll('[data-verse-footnote="51"]');
+ expect(placeholders.length).toBe(2);
const footnoteElements = container.querySelectorAll('.yv-n.f');
expect(footnoteElements.length).toBe(0);
});
- const footnoteButton = container.querySelector('[data-verse-footnote="51"] button');
- expect(footnoteButton).not.toBeNull();
+ const footnoteButtons = container.querySelectorAll('[data-verse-footnote="51"] button');
+ expect(footnoteButtons.length).toBe(2);
- await userEvent.click(footnoteButton!);
+ await userEvent.click(footnoteButtons[0]!);
await waitFor(() => {
const popover = document.body.querySelector('[role="dialog"]');
@@ -317,6 +317,39 @@ describe('Verse.Html - Footnotes', () => {
expect(listItems?.length).toBe(2);
});
});
+
+ it('should use alphabetic markers beyond z in the popover list', async () => {
+ const repeatedFootnotes = Array.from({ length: 27 }, () => {
+ return '1:1 Footnote text';
+ }).join('');
+
+ const htmlWithManyFootnotes = `
+
+ 1Text ${repeatedFootnotes}
+
+ `;
+
+ const { container } = render();
+
+ const footnoteButton = await waitFor(() => {
+ const button = container.querySelector('[data-verse-footnote="1"] button');
+ expect(button).not.toBeNull();
+ return button as HTMLButtonElement;
+ });
+
+ await userEvent.click(footnoteButton);
+
+ await waitFor(() => {
+ const popover = document.body.querySelector('[role="dialog"]');
+ expect(popover).not.toBeNull();
+
+ const listItems = popover?.querySelectorAll('ul li');
+ expect(listItems?.length).toBe(27);
+
+ const marker27 = listItems?.[26]?.querySelector('span')?.textContent;
+ expect(marker27).toBe('aa.');
+ });
+ });
});
describe('Verse.Html - Footnote spacing', () => {
@@ -351,6 +384,22 @@ describe('Verse.Html - Footnote spacing', () => {
expect(text).not.toContain('overcome .');
});
});
+
+ it('should insert spacing when adjacent siblings are element nodes', async () => {
+ const htmlWithElementSiblings = `
+
+ 5overcome1:5 Note textit.
+
+ `;
+
+ const { container } = render();
+
+ await waitFor(() => {
+ const text = container.textContent ?? '';
+ expect(text).toContain('overcome it.');
+ expect(text).not.toContain('overcomeit.');
+ });
+ });
});
describe('Verse.Html - Verse Wrapping', () => {
diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx
index 461f331e..7c540691 100644
--- a/packages/ui/src/components/verse.tsx
+++ b/packages/ui/src/components/verse.tsx
@@ -1,12 +1,11 @@
'use client';
import { usePassage, useTheme } from '@youversion/platform-react-hooks';
-import DOMPurify from 'isomorphic-dompurify';
import {
forwardRef,
memo,
type ReactNode,
- useEffect,
+ useMemo,
useLayoutEffect,
useRef,
useState,
@@ -14,20 +13,23 @@ import {
import { createPortal } from 'react-dom';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
- extractNotesFromWrappedHtml,
- LETTERS,
- NON_BREAKING_SPACE,
+ getFootnoteMarker,
+ transformBibleHtml,
type FontFamily,
type VerseNotes,
- wrapVerseContent,
} from '@/lib/verse-html-utils';
import { Footnote } from './icons/footnote';
-type ExtractedNotes = {
+type TransformedBibleHtml = {
html: string;
notes: Record;
};
+type VerseFootnotePlaceholder = {
+ verseNum: string;
+ el: Element;
+};
+
const VerseFootnoteButton = memo(function VerseFootnoteButton({
verseNum,
verseNotes,
@@ -66,16 +68,19 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({
dangerouslySetInnerHTML={{ __html: verseNotes.verseHtml }}
/>
- {verseNotes.notes.map((note, index) => (
- -
- {LETTERS[index] || index + 1}.
- {/** biome-ignore lint/security/noDangerouslySetInnerHtml: HTML has been run through DOMPurify and is safe */}
-
-
- ))}
+ {verseNotes.notes.map((note, index) => {
+ const marker = getFootnoteMarker(index);
+ return (
+ -
+ {marker}.
+ {/** biome-ignore lint/security/noDangerouslySetInnerHtml: HTML has been run through DOMPurify and is safe */}
+
+
+ );
+ })}
@@ -103,74 +108,54 @@ function BibleTextHtml({
highlightedVerses?: Record;
}) {
const contentRef = useRef(null);
- const [placeholders, setPlaceholders] = useState