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

+3
View File
@@ -36,6 +36,9 @@ export function cleanMarks(body) {
};
// The highlighted words, kept so lists of highlights can show them without loading the chapter
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
// Which translation the words were selected in, and whether it is a whole-verse highlight
if (typeof h.translation === 'string' && /^[A-Za-z0-9]{2,8}$/.test(h.translation)) entry.translation = h.translation;
if (h.whole === true) entry.whole = true;
highlights.push(entry);
}
}
+12
View File
@@ -58,3 +58,15 @@ test('the database keeps one row per user and updatedAt only moves forward', asy
expect(saved.highlights).toHaveLength(1);
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
});
test('a highlight keeps the translation it was made in and whether it covers the whole verse', async () => {
const { cleanMarks } = await import('./readerMarks.js');
const base = { id: 'a', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a', text: 'For God so loved' };
const out = cleanMarks({ highlights: [
{ ...base, translation: 'BSB', whole: true },
{ ...base, id: 'b', translation: 'not a code!!', whole: 'yes' },
] }).highlights;
expect(out[0]).toMatchObject({ translation: 'BSB', whole: true });
expect(out[1].translation).toBeUndefined();
expect(out[1].whole).toBeUndefined();
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { isWholeVerse } from '../../utils/highlightSpans.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import VerseShareImage from '../VerseShareImage.jsx';
import IqLexicon from '../iq/IqLexicon.jsx';
@@ -223,7 +224,7 @@ export default function VerseSheet({
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
{HIGHLIGHT_COLORS.map((color) => {
const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length);
const on = verseHighlights.some((h) => h.color === color && isWholeVerse(h, verse.text));
return (
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
@@ -0,0 +1,23 @@
import { test, expect } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// Contrast of two #rrggbb colours (WCAG ratio), used to make sure selected text can be seen on every theme.
const lum = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const mix = (bg, rgba) => {
const [r, g, b, a] = rgba.match(/[\d.]+/g).map(Number);
return `#${[r, g, b].map((c, i) => Math.round(c * a + parseInt(bg.slice(1 + i * 2, 3 + i * 2), 16) * (1 - a)).toString(16).padStart(2, '0')).join('')}`;
};
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
test('every theme sets a selection colour that is different from the page and keeps text readable', () => {
for (const [id, theme] of Object.entries(READER_THEMES)) {
const v = theme.vars;
expect(v['--r-sel'], id).toBeTruthy();
const selected = mix(v['--r-bg'], v['--r-sel']);
expect(ratio(selected, v['--r-bg']), `${id}: selection stands out from the page`).toBeGreaterThan(1.25);
expect(ratio(selected, v['--r-text']), `${id}: text stays readable on the selection`).toBeGreaterThan(4);
}
});
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = {
paper: {
label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-sel': 'rgba(168, 78, 39, 0.3)', '--r-on': '#ffffff', '--r-danger': '#b42318' },
},
sepia: {
label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-sel': 'rgba(147, 71, 29, 0.32)', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
},
dark: {
label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-sel': 'rgba(224, 137, 92, 0.5)', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
},
};
+2 -1
View File
@@ -19,8 +19,9 @@ body {
color: #0f172a;
}
/* Selected text follows the reading theme, so it stays visible on dark as well as light pages. */
::selection {
background: rgba(15, 23, 42, 0.18);
background: var(--r-sel, rgba(15, 23, 42, 0.18));
}
button,
+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);
+46
View File
@@ -0,0 +1,46 @@
// Where a saved text highlight lands in the verse being shown. A highlight remembers the words that were
// selected and the translation they were selected in, so it can follow you into another translation:
// the same words are found again where they match, a whole-verse highlight covers the whole verse, and
// when the other translation words it differently the verse gets a small colour dot instead of a wrong mark.
// A highlight of the whole verse (made with the verse colours, not by selecting words).
export const isWholeVerse = (h, verseText) => !!h.whole || (h.startOffset === 0 && h.endOffset >= verseText.length);
const isWordChar = (ch) => /[\p{L}\p{N}]/u.test(ch);
// Finds `needle` inside `text` ignoring case, punctuation and spacing differences ("God said," = "God said").
// -> { start, end } in `text`, or null. Needs at least 3 letters so a stray "of" can't match anywhere.
export function findWords(text, needle) {
const squash = (s) => {
let out = '';
const at = [];
for (let i = 0; i < s.length; i += 1) if (isWordChar(s[i])) { out += s[i].toLowerCase(); at.push(i); }
return { out, at };
};
const hay = squash(text);
const want = squash(needle).out;
if (want.length < 3) return null;
const i = hay.out.indexOf(want);
if (i === -1) return null;
return { start: hay.at[i], end: hay.at[i + want.length - 1] + 1 };
}
// -> { spans: [{ start, end, id, color }], ghosts: [{ id, color, translation, text }] }
export function placeHighlights(highlights, verseText, translation) {
const spans = [];
const ghosts = [];
for (const h of highlights) {
if (isWholeVerse(h, verseText)) { spans.push({ start: 0, end: verseText.length, id: h.id, color: h.color }); continue; }
const sameBible = !h.translation || h.translation === translation;
const validOffsets = Number.isInteger(h.startOffset) && Number.isInteger(h.endOffset) && h.startOffset >= 0 && h.endOffset > h.startOffset && h.endOffset <= verseText.length;
// The saved positions are trusted when they still pick out the words that were selected
if (sameBible && validOffsets && (!h.text || verseText.slice(h.startOffset, h.endOffset) === h.text)) {
spans.push({ start: h.startOffset, end: h.endOffset, id: h.id, color: h.color });
continue;
}
const found = h.text ? findWords(verseText, h.text) : null;
if (found) spans.push({ ...found, id: h.id, color: h.color });
else ghosts.push({ id: h.id, color: h.color, translation: h.translation ?? '', text: h.text ?? '' });
}
return { spans, ghosts };
}
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from 'vitest';
import { findWords, isWholeVerse, placeHighlights } from './highlightSpans.js';
const BSB = 'In the beginning God created the heavens and the earth.';
const KJV = 'In the beginning God created the heaven and the earth.';
const hl = (extra) => ({ id: 'h', color: '#fef08a', startOffset: 3, endOffset: 16, text: 'the beginning', translation: 'BSB', ...extra });
test('in the same translation the saved positions are used', () => {
expect(placeHighlights([hl()], BSB, 'BSB').spans).toEqual([{ start: 3, end: 16, id: 'h', color: '#fef08a' }]);
});
test('in another translation the same words are found where they are, even if they sit elsewhere', () => {
const other = `And so, ${KJV}`;
const placed = placeHighlights([hl()], other, 'KJV');
expect(placed.ghosts).toEqual([]);
expect(other.slice(placed.spans[0].start, placed.spans[0].end)).toBe('the beginning');
});
test('matching ignores case, punctuation and spacing', () => {
expect(findWords('And God said, “Let there be light,” and there was light.', 'God said Let there be light')).toEqual({ start: 4, end: 'And God said, “Let there be light'.length });
expect(findWords('Grace and peace', 'GRACE and PEACE')).toEqual({ start: 0, end: 15 });
expect(findWords('Grace and peace', 'of')).toBeNull(); // too short to trust
expect(findWords('Grace and peace', 'mercy')).toBeNull();
});
test('words the other translation puts differently leave a dot, not a wrong mark', () => {
const placed = placeHighlights([hl({ startOffset: 22, endOffset: 40, text: 'created the heavens', translation: 'BSB' })], 'In the beginning God made heaven and earth.', 'KJV');
expect(placed.spans).toEqual([]);
expect(placed.ghosts).toEqual([{ id: 'h', color: '#fef08a', translation: 'BSB', text: 'created the heavens' }]);
});
test('a whole-verse highlight covers the whole verse in every translation', () => {
const whole = hl({ startOffset: 0, endOffset: BSB.length, text: BSB, whole: true });
expect(placeHighlights([whole], 'A much longer verse in another Bible than the first one was.', 'NET').spans).toEqual([{ start: 0, end: 'A much longer verse in another Bible than the first one was.'.length, id: 'h', color: '#fef08a' }]);
expect(isWholeVerse({ startOffset: 0, endOffset: 500 }, BSB)).toBe(true); // made before the flag existed
expect(isWholeVerse(hl(), BSB)).toBe(false);
});
test('older highlights with no translation are checked against their words, not trusted blindly', () => {
const old = { id: 'o', color: '#bbf7d0', startOffset: 3, endOffset: 16, text: 'the beginning' }; // no translation saved
expect(placeHighlights([old], BSB, 'BSB').spans).toHaveLength(1);
expect(placeHighlights([old], 'Before time began, God began.', 'NET').ghosts).toHaveLength(1); // the offsets would mark the wrong words here
expect(placeHighlights([{ ...old, text: undefined }], BSB, 'BSB').spans).toHaveLength(1);
});