diff --git a/apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts b/apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts new file mode 100644 index 00000000..f1de907e --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { shouldCheckAsFootnote } from '@/app/reader/utils/footnoteHeuristics'; + +const parseHtml = (html: string) => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + return doc.body; +}; + +const getAnchorByText = (root: Element, text: string) => { + for (const a of Array.from(root.querySelectorAll('a'))) { + if ((a.textContent || '').trim() === text) return a as HTMLAnchorElement; + } + throw new Error(`anchor with text "${text}" not found`); +}; + +describe('shouldCheckAsFootnote', () => { + it('returns false for non-numeric link text', () => { + const body = parseHtml('
Body textgo here more text
'); + const a = getAnchorByText(body, 'go here'); + expect(shouldCheckAsFootnote(a)).toBe(false); + }); + + it('returns true for an isolated short-numeric link in body text', () => { + const body = parseHtml( + 'The quick brown fox1 jumps over the lazy dog.
', + ); + const a = getAnchorByText(body, '1'); + expect(shouldCheckAsFootnote(a)).toBe(true); + }); + + it('returns false when the link is part of an inline verse-number list (OSB v2 layout)', () => { + // Mirrors the in-book "Verses in Chapter 32" TOC layout: a flat list of + // numeric verse links wrapped in nested spans, separated by commas. + const body = parseHtml(` + + `); + const a = getAnchorByText(body, '1'); + expect(shouldCheckAsFootnote(a)).toBe(false); + }); + + it('returns false when the link is part of a pipe-separated chapter list (OSB v4 layout)', () => { + // Mirrors the in-book chapter index: 1 | 2 | ... | 34 + const body = parseHtml(` ++ 1 | + 2 | + 3 | + 4 | + 32 +
+ `); + const a = getAnchorByText(body, '32'); + expect(shouldCheckAsFootnote(a)).toBe(false); + }); + + it('still recognises a real footnote marker even when one other footnote sits in the same paragraph', () => { + // A single body paragraph with two footnote markers is plausible; only + // 2+ *additional* numeric siblings should disqualify. + const body = parseHtml( + 'First reference1 and a second sentence2 finishes.
', + ); + const a = getAnchorByText(body, '1'); + expect(shouldCheckAsFootnote(a)).toBe(true); + }); + + it('returns false when 2+ sibling numeric links share the container', () => { + const body = parseHtml(''); + const a = getAnchorByText(body, '1'); + expect(shouldCheckAsFootnote(a)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx index 3b21b65d..2a57e6cc 100644 --- a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx +++ b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx @@ -13,6 +13,7 @@ import { getPopupPosition, getPosition, Position } from '@/utils/sel'; import { FootnoteHandler } from 'foliate-js/footnotes.js'; import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts'; import { eventDispatcher } from '@/utils/event'; +import { shouldCheckAsFootnote } from '../utils/footnoteHeuristics'; import { FoliateView } from '@/types/view'; import { isCJKLang } from '@/utils/lang'; import { Overlay } from '@/components/Overlay'; @@ -246,7 +247,7 @@ const FootnotePopup: React.FC