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

+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);
});