From 22ad12e6772b390d884c31e8e7c772e801926aa9 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 13 Aug 2026 15:03:08 -0400 Subject: [PATCH] =?UTF-8?q?Add=20text=20highlighting=20in=20reader=20?= =?UTF-8?q?=E2=80=94=20select=20text=20to=20color,=20visible=20in=20read?= =?UTF-8?q?=20and=20draw=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 26 +++++++ src/pages/ReaderPage.jsx | 155 ++++++++++++++++++++++++++++++++++----- 2 files changed, 162 insertions(+), 19 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 7bafed0..f4f3434 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1199,6 +1199,12 @@ const App = () => { try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); } catch { return {}; } }); + const [readerTextHighlights, setReaderTextHighlights] = useState(() => { + try { + const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]'); + return Array.isArray(val) ? val : []; + } catch { return []; } + }); // Debounce timer ref so rapid stroke updates don't flood the server const inkSaveTimerRef = useRef({}); const audioRef = useRef(null); @@ -1582,6 +1588,23 @@ const App = () => { }); }; + const addTextHighlight = (highlight) => { + const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + setReaderTextHighlights(prev => { + const next = [...prev, { id, ...highlight }]; + try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {} + return next; + }); + }; + + const removeTextHighlight = (id) => { + setReaderTextHighlights(prev => { + const next = prev.filter(h => h.id !== id); + try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {} + return next; + }); + }; + const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => { const cacheKey = `${bookAbbrev}/${chapterNumber}`; if (_readerCrossRefCacheRef.current[cacheKey]) { @@ -3772,6 +3795,9 @@ const deleteProject = (id) => { readerAudioState, readerInkByPage, updateReaderPageInk, + readerTextHighlights, + addTextHighlight, + removeTextHighlight, bibleIndexStatus, loadReaderChapter, _bibleIndexCacheRef, diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 849bfee..7b25d75 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -45,11 +45,94 @@ export default function ReaderPage() { setReaderCrossRefs, readerInkByPage, updateReaderPageInk, + readerTextHighlights, + addTextHighlight, + removeTextHighlight, } = useApp(); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const [readerDrawMode, setReaderDrawMode] = useState(false); const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? []; + const [selectionPicker, setSelectionPicker] = useState(null); + + // Dismiss the highlight picker when tapping outside it + useEffect(() => { + if (!selectionPicker) return; + const dismiss = () => setSelectionPicker(null); + document.addEventListener('pointerdown', dismiss); + return () => document.removeEventListener('pointerdown', dismiss); + }, [selectionPicker]); + + // Render verse text with colored highlights and optional search match highlight + function renderVerseText(verseNum, text, searchLow) { + const spans = []; + for (const h of (readerTextHighlights || [])) { + if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) { + spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color }); + } + } + if (searchLow) { + const lower = text.toLowerCase(); + let i = 0; + while ((i = lower.indexOf(searchLow, i)) !== -1) { + spans.push({ start: i, end: i + searchLow.length, type: 'search' }); + i += searchLow.length; + } + } + if (!spans.length) return text; + spans.sort((a, b) => a.start - b.start); + const parts = []; + let cursor = 0; + for (const s of spans) { + if (s.start < cursor) continue; + if (s.start > cursor) parts.push(text.slice(cursor, s.start)); + if (s.type === 'hl') { + parts.push( + removeTextHighlight(s.id)} title="Click to remove highlight"> + {text.slice(s.start, s.end)} + + ); + } else { + parts.push( + + {text.slice(s.start, s.end)} + + ); + } + cursor = s.end; + } + if (cursor < text.length) parts.push(text.slice(cursor)); + return <>{parts}; + } + + // Detect text selection and show the highlight color picker + function handleSelectionEnd() { + setTimeout(() => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || !sel.toString().trim()) return; + const range = sel.getRangeAt(0); + const rect = range.getBoundingClientRect(); + if (!rect.width) return; + let node = range.commonAncestorContainer; + if (node.nodeType !== 1) node = node.parentNode; + while (node && !(node.dataset && node.dataset.verseNum)) node = node.parentElement; + if (!node) return; + const verseNum = Number(node.dataset.verseNum); + const verse = readerVerses.find(v => v.number === verseNum); + if (!verse) return; + const selectedText = sel.toString(); + const startOffset = verse.text.indexOf(selectedText); + if (startOffset === -1) return; + setSelectionPicker({ + x: (rect.left + rect.right) / 2, + y: rect.top, + verse: verseNum, + startOffset, + endOffset: startOffset + selectedText.length, + }); + }, 20); + } const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => { const [bAbbrev, chapterStr, verseStr] = key.split('-'); @@ -321,7 +404,8 @@ export default function ReaderPage() { return

No verses match "{readerSearch}".

; } return ( -
+
{filtered.map((verse) => { const chapterInterlinear = readerInterlinear?.[String(readerChapter)]; const verseWords = chapterInterlinear?.[String(verse.number)]; @@ -330,21 +414,9 @@ export default function ReaderPage() { const bmColor = readerBookmarks[verseKey]; const crossRefs = readerCrossRefs?.[verse.number]; - const highlightText = (text) => { - if (!searchLower) return text; - const idx = text.toLowerCase().indexOf(searchLower); - if (idx === -1) return text; - return ( - <> - {text.slice(0, idx)} - {text.slice(idx, idx + searchLower.length)} - {text.slice(idx + searchLower.length)} - - ); - }; - return ( -
-

{highlightText(verse.text)}

+

{renderVerseText(verse.number, verse.text, searchLower)}

+
+ )}
); }