Reader: visible text selection in every theme; highlights follow across translations
Build and Push Docker Image / build-and-push (push) Failing after 19s

Selection
- Selected text uses a per-theme colour (it was nearly invisible on Dark)
- The colour picker also appears on touch screens after dragging the selection
  handles, and sits below the selection there so the system menu doesn't cover it

Highlights
- Each highlight remembers its translation and whether it covers the whole verse
- In another translation the same words are found again (ignoring case and
  punctuation); whole-verse highlights cover the whole verse; when the wording
  differs, the verse gets a small colour dot instead of marking the wrong words
- Older highlights are checked against their saved words instead of trusted
- Server keeps the new fields

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 09:55:09 -04:00
1 parent aabd4267b8
commit a0971eab29
9 files changed
+189 -38

No files matched your search

+54 -33
View File
@@ -1,6 +1,7 @@
// The reading side of the app: a distraction-free page with slim chrome. Every
// tool (navigation, search, settings, audio, verse study) lives in a sheet.
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { placeHighlights, isWholeVerse } from '../utils/highlightSpans.js';
import { useState, useEffect, useRef, useMemo, useDeferredValue, useCallback } from 'react';
import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
@@ -308,9 +309,9 @@ export default function ReaderPage() {
// Highlighting a whole verse from the verse panel: tapping the same colour again takes it off.
const highlightVerse = (color) => {
if (!selectedVerse) return;
const existing = verseHighlights.find((h) => h.color === color && h.startOffset === 0 && h.endOffset >= selectedVerse.text.length);
const existing = verseHighlights.find((h) => h.color === color && isWholeVerse(h, selectedVerse.text));
if (existing) { removeHighlight(existing.id); return; }
addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text });
addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text, translation: readerTranslation, whole: true });
};
const clearVerseHighlights = () => {
const gone = verseHighlights;
@@ -327,11 +328,13 @@ export default function ReaderPage() {
function renderVerseText(verseNum, text, searchLow, diffRanges) {
const spans = [];
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
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 });
}
}
// Your highlights follow you between translations: the same words where they match, otherwise a dot
const placed = placeHighlights((readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum), text, readerTranslation);
for (const h of placed.spans) spans.push({ start: h.start, end: h.end, type: 'hl', id: h.id, color: h.color });
const dots = placed.ghosts.map((g) => (
<span key={`ghost${g.id}`} className="mr-1 inline-block h-2 w-2 rounded-full align-middle ring-1 ring-black/15" style={{ backgroundColor: g.color }}
title={`Highlighted${g.translation ? ` in ${g.translation}` : ''}${g.text ? `: “${g.text}”` : ''}`} />
));
if (searchLow) {
const lower = text.toLowerCase();
let i = 0;
@@ -340,7 +343,7 @@ export default function ReaderPage() {
i += searchLow.length;
}
}
if (!spans.length) return text;
if (!spans.length) return dots.length ? <>{dots}{text}</> : text;
// Your highlights and search matches always show; a difference mark that would overlap one is dropped
const firm = spans.filter((x) => x.type !== 'diff');
for (let i = spans.length - 1; i >= 0; i -= 1) {
@@ -368,30 +371,48 @@ export default function ReaderPage() {
cursor = s.end;
}
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
return <>{dots}{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, text: selectedText });
}, 20);
}
// Detect text selection and show the highlight color picker. Runs when a mouse/pen selection ends
// (pointerup) and, for touch, whenever the selection settles (selectionchange): on a phone or iPad,
// dragging the selection handles doesn't produce a pointerup.
const readSelection = useCallback(() => {
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, bottom: rect.bottom, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
}, [readerVerses]);
function handleSelectionEnd() { setTimeout(readSelection, 20); }
useEffect(() => {
let timer;
const onChange = () => { window.clearTimeout(timer); timer = window.setTimeout(readSelection, 350); };
document.addEventListener('selectionchange', onChange);
return () => { window.clearTimeout(timer); document.removeEventListener('selectionchange', onChange); };
}, [readSelection]);
// Where the picker sits. On a touch screen the system's own Copy / Look Up menu appears just above the
// selection, so the colours go just below it (and always stay on screen); with a mouse they go above.
const pickerTop = (() => {
if (!selectionPicker) return 0;
const touch = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
const below = selectionPicker.bottom + 12;
const above = selectionPicker.y - 56;
const wanted = touch || above < 64 ? below : above;
return Math.max(64, Math.min(wanted, window.innerHeight - 130));
})();
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
const [bAbbrev, chapterStr, verseStr] = key.split('-');
@@ -633,7 +654,7 @@ export default function ReaderPage() {
className="fixed z-[60] flex items-center gap-1.5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-2 shadow-2xl"
style={{
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
top: selectionPicker.y > 80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
top: pickerTop,
}}
onPointerDown={(e) => e.stopPropagation()}
>
@@ -649,7 +670,7 @@ export default function ReaderPage() {
addTextHighlight({
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
text: selectionPicker.text,
text: selectionPicker.text, translation: readerTranslation,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);