Reader: visible text selection in every theme; highlights follow across translations
Build and Push Docker Image / build-and-push (push) Failing after 19s
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:
1 parent
aabd4267b8
commit
a0971eab29
9 files changed
+174
-23
No files matched your search
@@ -36,6 +36,9 @@ export function cleanMarks(body) {
|
|||||||
};
|
};
|
||||||
// The highlighted words, kept so lists of highlights can show them without loading the chapter
|
// 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);
|
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);
|
highlights.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,15 @@ test('the database keeps one row per user and updatedAt only moves forward', asy
|
|||||||
expect(saved.highlights).toHaveLength(1);
|
expect(saved.highlights).toHaveLength(1);
|
||||||
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
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();
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
|||||||
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
|
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
|
||||||
import { DiffText } from './DiffText.jsx';
|
import { DiffText } from './DiffText.jsx';
|
||||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||||
|
import { isWholeVerse } from '../../utils/highlightSpans.js';
|
||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import VerseShareImage from '../VerseShareImage.jsx';
|
import VerseShareImage from '../VerseShareImage.jsx';
|
||||||
import IqLexicon from '../iq/IqLexicon.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">
|
<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>
|
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
|
||||||
{HIGHLIGHT_COLORS.map((color) => {
|
{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 (
|
return (
|
||||||
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
|
<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' : ''}`}
|
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,15 +3,15 @@
|
|||||||
export const READER_THEMES = {
|
export const READER_THEMES = {
|
||||||
paper: {
|
paper: {
|
||||||
label: '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: {
|
sepia: {
|
||||||
label: '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: {
|
dark: {
|
||||||
label: '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
@@ -19,8 +19,9 @@ body {
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selected text follows the reading theme, so it stays visible on dark as well as light pages. */
|
||||||
::selection {
|
::selection {
|
||||||
background: rgba(15, 23, 42, 0.18);
|
background: var(--r-sel, rgba(15, 23, 42, 0.18));
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
|||||||
+39
-18
@@ -1,6 +1,7 @@
|
|||||||
// The reading side of the app: a distraction-free page with slim chrome. Every
|
// 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.
|
// 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 { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
|
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.
|
// Highlighting a whole verse from the verse panel: tapping the same colour again takes it off.
|
||||||
const highlightVerse = (color) => {
|
const highlightVerse = (color) => {
|
||||||
if (!selectedVerse) return;
|
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; }
|
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 clearVerseHighlights = () => {
|
||||||
const gone = verseHighlights;
|
const gone = verseHighlights;
|
||||||
@@ -327,11 +328,13 @@ export default function ReaderPage() {
|
|||||||
function renderVerseText(verseNum, text, searchLow, diffRanges) {
|
function renderVerseText(verseNum, text, searchLow, diffRanges) {
|
||||||
const spans = [];
|
const spans = [];
|
||||||
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
|
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
|
||||||
for (const h of (readerTextHighlights || [])) {
|
// Your highlights follow you between translations: the same words where they match, otherwise a dot
|
||||||
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
|
const placed = placeHighlights((readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum), text, readerTranslation);
|
||||||
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
|
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) {
|
if (searchLow) {
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -340,7 +343,7 @@ export default function ReaderPage() {
|
|||||||
i += searchLow.length;
|
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
|
// Your highlights and search matches always show; a difference mark that would overlap one is dropped
|
||||||
const firm = spans.filter((x) => x.type !== 'diff');
|
const firm = spans.filter((x) => x.type !== 'diff');
|
||||||
for (let i = spans.length - 1; i >= 0; i -= 1) {
|
for (let i = spans.length - 1; i >= 0; i -= 1) {
|
||||||
@@ -368,12 +371,13 @@ export default function ReaderPage() {
|
|||||||
cursor = s.end;
|
cursor = s.end;
|
||||||
}
|
}
|
||||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||||
return <>{parts}</>;
|
return <>{dots}{parts}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect text selection and show the highlight color picker
|
// Detect text selection and show the highlight color picker. Runs when a mouse/pen selection ends
|
||||||
function handleSelectionEnd() {
|
// (pointerup) and, for touch, whenever the selection settles (selectionchange): on a phone or iPad,
|
||||||
setTimeout(() => {
|
// dragging the selection handles doesn't produce a pointerup.
|
||||||
|
const readSelection = useCallback(() => {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
||||||
const range = sel.getRangeAt(0);
|
const range = sel.getRangeAt(0);
|
||||||
@@ -389,9 +393,26 @@ export default function ReaderPage() {
|
|||||||
const selectedText = sel.toString();
|
const selectedText = sel.toString();
|
||||||
const startOffset = verse.text.indexOf(selectedText);
|
const startOffset = verse.text.indexOf(selectedText);
|
||||||
if (startOffset === -1) return;
|
if (startOffset === -1) return;
|
||||||
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
|
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, bottom: rect.bottom, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
|
||||||
}, 20);
|
}, [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 bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
|
||||||
const [bAbbrev, chapterStr, verseStr] = key.split('-');
|
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"
|
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={{
|
style={{
|
||||||
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
|
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()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -649,7 +670,7 @@ export default function ReaderPage() {
|
|||||||
addTextHighlight({
|
addTextHighlight({
|
||||||
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
|
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
|
||||||
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
|
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
|
||||||
text: selectionPicker.text,
|
text: selectionPicker.text, translation: readerTranslation,
|
||||||
});
|
});
|
||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
setSelectionPicker(null);
|
setSelectionPicker(null);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user