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
+189
-38
No files matched your search
+54
-33
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user