837b81b038
Adds a "Page" toggle in the reader tool strip. When enabled, the verse panel becomes a fixed-height container using CSS column-width to create one column per page-width. Content flows naturally into successive pages; long chapters simply have more pages. Bottom nav shows ← Back/Prev ch. and Next/Next ch. → with a page counter (e.g. Genesis 1 · 2/3). Swipe left/right and arrow keys also navigate pages before crossing chapter boundaries. Preference persisted to localStorage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
903 lines
47 KiB
React
903 lines
47 KiB
React
import { useState, useEffect, useRef } from 'react';
|
||
import { useApp } from '../context/AppContext.js';
|
||
import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx';
|
||
import DrawCanvas from './DrawCanvas.jsx';
|
||
|
||
export default function ReaderPage() {
|
||
const {
|
||
authStatus,
|
||
goHome,
|
||
readerBookAbbrev,
|
||
readerChapter, setReaderChapter,
|
||
readerVerses,
|
||
readerTotalChapters,
|
||
readerLoading,
|
||
readerError,
|
||
readerInterlinear,
|
||
readerSelectedVerse, setReaderSelectedVerse,
|
||
readerFontSize, setReaderFontSize,
|
||
readerBookmarks,
|
||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||
readerCrossRefs,
|
||
readerCrossRefsLoading,
|
||
readerShowCrossRefs, setReaderShowCrossRefs,
|
||
readerSearch, setReaderSearch,
|
||
readerSearchActive, setReaderSearchActive,
|
||
readerSearchScope, setReaderSearchScope,
|
||
readerAudioState,
|
||
bibleIndexStatus,
|
||
audioNarrator, setAudioNarrator,
|
||
_bibleIndexCacheRef,
|
||
handleReaderBookChange,
|
||
readerGoToPreviousChapter,
|
||
readerGoToNextChapter,
|
||
jumpToReaderVerse,
|
||
toggleReaderBookmark,
|
||
cycleBookmarkColor,
|
||
loadReaderCrossRefs,
|
||
loadBibleIndex,
|
||
handlePlayReaderAudio,
|
||
handleToggleReaderAudioPause,
|
||
handleStopBookAudio,
|
||
speakOriginalWord,
|
||
copyVerse,
|
||
formatCrossRef,
|
||
setReaderCrossRefs,
|
||
readerInkByPage,
|
||
updateReaderPageInk,
|
||
readerTextHighlights,
|
||
addTextHighlight,
|
||
removeTextHighlight,
|
||
} = useApp();
|
||
|
||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
|
||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||
const swipeTouchRef = useRef(null);
|
||
|
||
const [toolbarVisible, setToolbarVisible] = useState(true);
|
||
const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1');
|
||
const [flipPage, setFlipPage] = useState(0);
|
||
const [flipTotalPages, setFlipTotalPages] = useState(1);
|
||
const [flipPageWidth, setFlipPageWidth] = useState(0);
|
||
const flipContainerRef = useRef(null);
|
||
const flipContentRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
|
||
// Auto-collapse toolbar when entering wide mode, restore when leaving
|
||
setToolbarVisible(!readerWideLayout);
|
||
}, [readerWideLayout]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0');
|
||
}, [readerPageMode]);
|
||
|
||
// Reset to page 0 when navigating chapters
|
||
useEffect(() => {
|
||
setFlipPage(0);
|
||
}, [readerChapter, readerBookAbbrev]);
|
||
|
||
// Track container width for CSS column sizing
|
||
useEffect(() => {
|
||
if (!readerPageMode || !flipContainerRef.current) return;
|
||
const el = flipContainerRef.current;
|
||
const update = () => setFlipPageWidth(el.clientWidth);
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [readerPageMode, readerVerses]);
|
||
|
||
// Count pages after content renders
|
||
useEffect(() => {
|
||
if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return;
|
||
const t = setTimeout(() => {
|
||
const sw = flipContentRef.current?.scrollWidth ?? 0;
|
||
setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth)));
|
||
}, 120);
|
||
return () => clearTimeout(t);
|
||
}, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]);
|
||
|
||
// Swipe left/right to navigate chapters
|
||
const handleTouchStart = (e) => {
|
||
if (readerDrawMode) return;
|
||
const t = e.changedTouches[0];
|
||
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
|
||
};
|
||
const handleTouchEnd = (e) => {
|
||
if (readerDrawMode || !swipeTouchRef.current) return;
|
||
const t = e.changedTouches[0];
|
||
const dx = t.clientX - swipeTouchRef.current.x;
|
||
const dy = t.clientY - swipeTouchRef.current.y;
|
||
swipeTouchRef.current = null;
|
||
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
|
||
if (dx < 0) {
|
||
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
|
||
else readerGoToNextChapter();
|
||
} else {
|
||
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
|
||
else readerGoToPreviousChapter();
|
||
}
|
||
};
|
||
|
||
// Arrow keys for desktop
|
||
useEffect(() => {
|
||
const handleKey = (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||
if (e.key === 'ArrowLeft') {
|
||
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
|
||
else readerGoToPreviousChapter();
|
||
} else if (e.key === 'ArrowRight') {
|
||
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
|
||
else readerGoToNextChapter();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleKey);
|
||
return () => window.removeEventListener('keydown', handleKey);
|
||
}, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]);
|
||
|
||
// Dismiss the highlight picker when tapping outside it
|
||
useEffect(() => {
|
||
if (!selectionPicker) return;
|
||
const dismiss = () => setSelectionPicker(null);
|
||
document.addEventListener('pointerdown', dismiss);
|
||
return () => document.removeEventListener('pointerdown', dismiss);
|
||
}, [selectionPicker]);
|
||
|
||
// Render verse text with colored highlights and optional search match highlight
|
||
function renderVerseText(verseNum, text, searchLow) {
|
||
const spans = [];
|
||
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 });
|
||
}
|
||
}
|
||
if (searchLow) {
|
||
const lower = text.toLowerCase();
|
||
let i = 0;
|
||
while ((i = lower.indexOf(searchLow, i)) !== -1) {
|
||
spans.push({ start: i, end: i + searchLow.length, type: 'search' });
|
||
i += searchLow.length;
|
||
}
|
||
}
|
||
if (!spans.length) return text;
|
||
spans.sort((a, b) => a.start - b.start);
|
||
const parts = [];
|
||
let cursor = 0;
|
||
for (const s of spans) {
|
||
if (s.start < cursor) continue;
|
||
if (s.start > cursor) parts.push(text.slice(cursor, s.start));
|
||
if (s.type === 'hl') {
|
||
parts.push(
|
||
<mark key={s.id} style={{ backgroundColor: s.color, borderRadius: '2px', cursor: 'pointer' }}
|
||
onClick={() => removeTextHighlight(s.id)} title="Click to remove highlight">
|
||
{text.slice(s.start, s.end)}
|
||
</mark>
|
||
);
|
||
} else {
|
||
parts.push(
|
||
<mark key={`srch${s.start}`} className="bg-yellow-200 rounded px-0.5">
|
||
{text.slice(s.start, s.end)}
|
||
</mark>
|
||
);
|
||
}
|
||
cursor = s.end;
|
||
}
|
||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||
return <>{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,
|
||
});
|
||
}, 20);
|
||
}
|
||
|
||
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
|
||
const [bAbbrev, chapterStr, verseStr] = key.split('-');
|
||
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
|
||
return {
|
||
key, color,
|
||
bookAbbrev: bAbbrev,
|
||
bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
|
||
chapter: Number(chapterStr),
|
||
verse: Number(verseStr),
|
||
bookIndex,
|
||
};
|
||
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||
<div>
|
||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||
<h1 className="mt-2 text-2xl font-semibold">Read the Bible (BSB)</h1>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={goHome}
|
||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||
>
|
||
← Back to Studies
|
||
</button>
|
||
{authStatus}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<main
|
||
className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : readerWideLayout ? 'max-w-7xl' : 'max-w-3xl'}`}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchEnd={handleTouchEnd}
|
||
>
|
||
{/* Collapsible toolbar — hidden in wide focus mode */}
|
||
<div className={`transition-all duration-200 ${!toolbarVisible ? 'hidden' : ''}`}>
|
||
|
||
{/* Navigation + tools bar */}
|
||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
||
<label className="text-sm text-slate-600">
|
||
Book{' '}
|
||
<select
|
||
value={readerBookAbbrev}
|
||
onChange={(e) => handleReaderBookChange(e.target.value)}
|
||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||
>
|
||
{bookOptions.map((b) => (
|
||
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="text-sm text-slate-600">
|
||
Chapter{' '}
|
||
<select
|
||
value={readerChapter}
|
||
onChange={(e) => { setReaderChapter(Number(e.target.value)); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }}
|
||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||
>
|
||
{Array.from({ length: readerTotalChapters }, (_, i) => i + 1).map((num) => (
|
||
<option key={num} value={num}>{num}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||
‹ Prev
|
||
</button>
|
||
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
|
||
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||
Next ›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tool strip: font size · cross-refs · search */}
|
||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
|
||
<span className="text-xs text-slate-500 mr-1">Text size</span>
|
||
{[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
|
||
<button key={label} type="button"
|
||
onClick={() => setReaderFontSize(size)}
|
||
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition ${readerFontSize === size ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||
<button type="button"
|
||
onClick={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerShowCrossRefs ? 'bg-amber-100 text-amber-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
|
||
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
|
||
</button>
|
||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||
<div className="relative">
|
||
<button type="button"
|
||
onClick={() => setReaderBookmarksPanelOpen((v) => !v)}
|
||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
|
||
<BookmarkIcon filled={bookmarkEntries.length > 0} />
|
||
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
|
||
</button>
|
||
{readerBookmarksPanelOpen && (
|
||
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-3 shadow-lg">
|
||
{bookmarkEntries.length === 0 ? (
|
||
<p className="p-2 text-sm text-slate-500">
|
||
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
|
||
</p>
|
||
) : (
|
||
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||
{bookmarkEntries.map((entry) => (
|
||
<div key={entry.key} className="flex items-center gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50">
|
||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} />
|
||
<button type="button"
|
||
onClick={() => jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
|
||
className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
|
||
{entry.bookName} {entry.chapter}:{entry.verse}
|
||
</button>
|
||
<button type="button"
|
||
onClick={() => toggleReaderBookmark(entry.key)}
|
||
title="Remove bookmark"
|
||
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||
{readerSearchActive ? (
|
||
<div className="flex items-center gap-1">
|
||
<div className="flex overflow-hidden rounded-lg border border-slate-300">
|
||
<button type="button"
|
||
onClick={() => setReaderSearchScope('chapter')}
|
||
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'chapter' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||
This chapter
|
||
</button>
|
||
<button type="button"
|
||
onClick={() => { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
|
||
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||
Whole Bible
|
||
</button>
|
||
</div>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
value={readerSearch}
|
||
onChange={(e) => setReaderSearch(e.target.value)}
|
||
placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'}
|
||
className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||
/>
|
||
<button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }}
|
||
className="rounded-lg border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">✕</button>
|
||
</div>
|
||
) : (
|
||
<button type="button" onClick={() => setReaderSearchActive(true)}
|
||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||
🔍 Search
|
||
</button>
|
||
)}
|
||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setReaderDrawMode((v) => !v)}
|
||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerDrawMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||
>
|
||
✏ Draw
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setReaderWideLayout((v) => !v)}
|
||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||
>
|
||
⊞ Wide
|
||
</button>
|
||
{readerWideLayout && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setToolbarVisible(false)}
|
||
className="rounded-lg px-3 py-1 text-xs font-semibold border border-slate-300 text-slate-600 hover:bg-slate-50 transition"
|
||
>
|
||
✕ Hide
|
||
</button>
|
||
)}
|
||
{!readerDrawMode && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setReaderPageMode((v) => !v)}
|
||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerPageMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||
>
|
||
⎕ Page
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
</div>{/* end collapsible toolbar */}
|
||
|
||
{/* Whole-Bible search results */}
|
||
{readerSearchActive && readerSearchScope === 'bible' && (
|
||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||
{bibleIndexStatus === 'loading' && (
|
||
<p className="text-sm text-slate-500">Loading the full Bible for search — this happens once per visit (~7MB)…</p>
|
||
)}
|
||
{bibleIndexStatus === 'error' && (
|
||
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
|
||
)}
|
||
{bibleIndexStatus === 'ready' && (() => {
|
||
const q = readerSearch.trim().toLowerCase();
|
||
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
|
||
const index = _bibleIndexCacheRef.current.BSB ?? [];
|
||
const matches = index.filter((v) => v.text.toLowerCase().includes(q));
|
||
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||
const shown = matches.slice(0, 100);
|
||
return (
|
||
<div className="space-y-1">
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
|
||
</p>
|
||
<div className="max-h-96 space-y-1 overflow-y-auto">
|
||
{shown.map((v) => {
|
||
const idx = v.text.toLowerCase().indexOf(q);
|
||
return (
|
||
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button"
|
||
onClick={() => jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
|
||
className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
|
||
<span className="font-semibold text-slate-700">{v.bookName} {v.chapter}:{v.verse}</span>{' '}
|
||
<span className="text-slate-600">
|
||
{v.text.slice(0, idx)}
|
||
<mark className="rounded bg-yellow-200 px-0.5">{v.text.slice(idx, idx + q.length)}</mark>
|
||
{v.text.slice(idx + q.length)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Audio player */}
|
||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
||
<div className="flex-1">
|
||
<h3 className="text-sm font-semibold text-slate-900">Listen to this chapter</h3>
|
||
<p className="mt-1 text-xs text-slate-500">
|
||
{readerAudioState.status === 'error'
|
||
? "Couldn't load audio for this chapter/narrator."
|
||
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)}
|
||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
|
||
<option value="david">David</option>
|
||
<option value="hays">Hays</option>
|
||
<option value="souer">Souer</option>
|
||
</select>
|
||
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
|
||
<>
|
||
<button type="button" onClick={handleToggleReaderAudioPause}
|
||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
|
||
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
|
||
</button>
|
||
<button type="button" onClick={handleStopBookAudio}
|
||
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
|
||
Stop
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button type="button" onClick={handlePlayReaderAudio}
|
||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
|
||
Play
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Verses — normal scroll mode */}
|
||
{!readerDrawMode && !readerPageMode && (
|
||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||
</h2>
|
||
{readerInterlinear && (
|
||
<p className="mb-4 text-xs text-slate-400">Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy</p>
|
||
)}
|
||
{!readerInterlinear && (
|
||
<p className="mb-4 text-xs text-slate-400">Hover a verse for actions</p>
|
||
)}
|
||
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||
{readerError && <p className="text-sm text-rose-600">{readerError}</p>}
|
||
{!readerLoading && !readerError && (() => {
|
||
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||
const filtered = searchLower
|
||
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
|
||
: readerVerses;
|
||
if (searchLower && filtered.length === 0) {
|
||
return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||
}
|
||
return (
|
||
<div className={`leading-relaxed text-slate-800 ${readerWideLayout ? 'md:columns-2 md:gap-x-10 space-y-0' : 'space-y-3'}`}
|
||
style={{ fontSize: `${readerFontSize}em` }}
|
||
onPointerUp={handleSelectionEnd}>
|
||
{filtered.map((verse) => {
|
||
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
||
const verseWords = chapterInterlinear?.[String(verse.number)];
|
||
const isOpen = readerSelectedVerse === verse.number;
|
||
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
|
||
const bmColor = readerBookmarks[verseKey];
|
||
const crossRefs = readerCrossRefs?.[verse.number];
|
||
|
||
return (
|
||
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||
className="group break-inside-avoid rounded-xl transition mb-3"
|
||
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
|
||
<div className="flex items-start gap-1">
|
||
<button type="button"
|
||
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
|
||
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
|
||
verseWords
|
||
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
|
||
: 'cursor-default text-slate-400'
|
||
}`}
|
||
title={verseWords ? 'Show original words' : undefined}>
|
||
{verse.number}
|
||
</button>
|
||
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
|
||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||
<button type="button"
|
||
onClick={() => toggleReaderBookmark(verseKey)}
|
||
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
|
||
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
|
||
<BookmarkIcon filled={!!bmColor} />
|
||
</button>
|
||
{bmColor && (
|
||
<button type="button"
|
||
onClick={() => cycleBookmarkColor(verseKey)}
|
||
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
|
||
title="Change highlight colour">
|
||
🎨
|
||
</button>
|
||
)}
|
||
<button type="button"
|
||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||
title="Copy verse">
|
||
<CopyIcon />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{readerShowCrossRefs && crossRefs && (
|
||
<div className="mt-1 ml-6 flex flex-wrap gap-1">
|
||
{crossRefs.slice(0, 8).map((ref, i) => {
|
||
const label = formatCrossRef(ref);
|
||
return (
|
||
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
|
||
{label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && verseWords && (
|
||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{verseWords.map((w, i) => {
|
||
const isHebrew = w.s?.startsWith('H');
|
||
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||
return (
|
||
<div key={i} className="rounded-xl border border-sky-200 bg-white p-2 text-center shadow-sm"
|
||
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
|
||
{w.o}
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
|
||
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
|
||
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
|
||
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
|
||
{canSpeak && (
|
||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
|
||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
|
||
🔊 Speak
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Verses — page-flip mode */}
|
||
{!readerDrawMode && readerPageMode && (
|
||
<div ref={flipContainerRef}
|
||
className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel"
|
||
style={{ height: toolbarVisible ? 'calc(100dvh - 380px)' : 'calc(100dvh - 120px)' }}>
|
||
{readerLoading && <p className="p-6 text-sm text-slate-500">Loading…</p>}
|
||
{readerError && <p className="p-6 text-sm text-rose-600">{readerError}</p>}
|
||
{!readerLoading && !readerError && (() => {
|
||
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||
const filtered = searchLower
|
||
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
|
||
: readerVerses;
|
||
if (searchLower && filtered.length === 0) {
|
||
return <p className="p-6 text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||
}
|
||
return (
|
||
<div ref={flipContentRef}
|
||
className="h-full leading-relaxed text-slate-800"
|
||
style={{
|
||
fontSize: `${readerFontSize}em`,
|
||
columnWidth: flipPageWidth ? `${flipPageWidth}px` : '100%',
|
||
columnGap: 0,
|
||
paddingBottom: '3.5rem',
|
||
transform: flipPageWidth ? `translateX(${-flipPage * flipPageWidth}px)` : undefined,
|
||
transition: 'transform 0.3s ease',
|
||
}}
|
||
onPointerUp={handleSelectionEnd}>
|
||
<div className="px-6 pt-6 pb-3 break-inside-avoid">
|
||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||
</h2>
|
||
{readerInterlinear
|
||
? <p className="text-xs text-slate-400">Click a verse number to see original words & pronunciation</p>
|
||
: <p className="text-xs text-slate-400">Hover a verse for actions</p>
|
||
}
|
||
</div>
|
||
{filtered.map((verse) => {
|
||
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
||
const verseWords = chapterInterlinear?.[String(verse.number)];
|
||
const isOpen = readerSelectedVerse === verse.number;
|
||
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
|
||
const bmColor = readerBookmarks[verseKey];
|
||
const crossRefs = readerCrossRefs?.[verse.number];
|
||
|
||
return (
|
||
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||
className="group break-inside-avoid rounded-xl transition mb-3 px-6"
|
||
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: 'calc(1.5rem + 0.5rem)' } : {}}>
|
||
<div className="flex items-start gap-1">
|
||
<button type="button"
|
||
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
|
||
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
|
||
verseWords
|
||
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
|
||
: 'cursor-default text-slate-400'
|
||
}`}
|
||
title={verseWords ? 'Show original words' : undefined}>
|
||
{verse.number}
|
||
</button>
|
||
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
|
||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||
<button type="button"
|
||
onClick={() => toggleReaderBookmark(verseKey)}
|
||
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
|
||
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
|
||
<BookmarkIcon filled={!!bmColor} />
|
||
</button>
|
||
{bmColor && (
|
||
<button type="button"
|
||
onClick={() => cycleBookmarkColor(verseKey)}
|
||
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
|
||
title="Change highlight colour">
|
||
🎨
|
||
</button>
|
||
)}
|
||
<button type="button"
|
||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||
title="Copy verse">
|
||
<CopyIcon />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{readerShowCrossRefs && crossRefs && (
|
||
<div className="mt-1 ml-6 flex flex-wrap gap-1">
|
||
{crossRefs.slice(0, 8).map((ref, i) => {
|
||
const label = formatCrossRef(ref);
|
||
return (
|
||
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
|
||
{label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && verseWords && (
|
||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{verseWords.map((w, i) => {
|
||
const isHebrew = w.s?.startsWith('H');
|
||
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||
return (
|
||
<div key={i} className="rounded-xl border border-sky-200 bg-white p-2 text-center shadow-sm"
|
||
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
|
||
{w.o}
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
|
||
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
|
||
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
|
||
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
|
||
{canSpeak && (
|
||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
|
||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
|
||
🔊 Speak
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* Page navigation bar */}
|
||
<div className="absolute bottom-0 inset-x-0 flex items-center justify-between border-t border-slate-100 bg-white/95 px-6 py-2.5 backdrop-blur-sm">
|
||
<button
|
||
onClick={() => flipPage > 0 ? setFlipPage((p) => p - 1) : readerGoToPreviousChapter()}
|
||
disabled={flipPage === 0 && readerChapter <= 1}
|
||
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||
{flipPage === 0 ? '← Prev chapter' : '← Back'}
|
||
</button>
|
||
<span className="text-xs text-slate-400">
|
||
{readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => flipPage < flipTotalPages - 1 ? setFlipPage((p) => p + 1) : readerGoToNextChapter()}
|
||
disabled={flipPage === flipTotalPages - 1 && readerChapter >= readerTotalChapters}
|
||
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||
{flipPage === flipTotalPages - 1 ? 'Next chapter →' : 'Next →'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Draw mode — Bible text on left, notebook canvas on right */}
|
||
{readerDrawMode && (
|
||
<div className="flex flex-col items-start gap-4 sm:flex-row">
|
||
{/* Scripture panel — sticky so it stays in view while notebook scrolls */}
|
||
<div className="w-full rounded-3xl border border-slate-200 bg-slate-50 p-5 font-serif text-sm leading-relaxed text-slate-800 sm:w-72 sm:shrink-0 sm:sticky sm:top-6 sm:max-h-[80vh] sm:overflow-y-auto"
|
||
onPointerUp={handleSelectionEnd}>
|
||
<div className="mb-3">
|
||
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</span>
|
||
<span className="ml-2 text-xs text-slate-400">BSB</span>
|
||
<p className="mt-1 text-xs text-slate-400 font-sans">Select text to highlight</p>
|
||
</div>
|
||
{!readerLoading && readerVerses.map((v) => (
|
||
<p key={v.number} className="mb-2" data-verse-num={v.number}>
|
||
<span className="font-semibold text-slate-700">{v.number}.</span>{' '}
|
||
{renderVerseText(v.number, v.text, '')}
|
||
</p>
|
||
))}
|
||
</div>
|
||
{/* Notebook canvas */}
|
||
<div className="min-w-0 flex-1">
|
||
<DrawCanvas
|
||
strokes={readerPageInkStrokes}
|
||
onStrokesChange={(s) => updateReaderPageInk(readerBookAbbrev, readerChapter, s)}
|
||
onDone={() => setReaderDrawMode(false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{/* Wide focus mode — fixed top bar to restore toolbar */}
|
||
{readerWideLayout && !toolbarVisible && (
|
||
<div className="fixed top-0 inset-x-0 z-30 flex items-center justify-between gap-4 border-b border-slate-200/60 bg-white/80 px-4 py-2 backdrop-blur-sm">
|
||
<span className="text-sm font-semibold text-slate-700">
|
||
{readerBook?.name} {readerChapter}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
|
||
‹ Prev
|
||
</button>
|
||
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
|
||
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
|
||
Next ›
|
||
</button>
|
||
<button type="button" onClick={() => setToolbarVisible(true)}
|
||
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||
⚙ Tools
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Side chapter nav — fixed left/right arrows */}
|
||
{!readerDrawMode && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={readerGoToPreviousChapter}
|
||
disabled={readerChapter <= 1}
|
||
aria-label="Previous chapter"
|
||
className="fixed left-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={readerGoToNextChapter}
|
||
disabled={readerChapter >= readerTotalChapters}
|
||
aria-label="Next chapter"
|
||
className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
|
||
>
|
||
›
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Highlight color picker — appears above text selection */}
|
||
{selectionPicker && (
|
||
<div
|
||
className="fixed z-50 flex items-center gap-1.5 rounded-2xl bg-white px-2.5 py-2 shadow-2xl border border-slate-200"
|
||
style={{
|
||
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
|
||
top: selectionPicker.y > 80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
|
||
}}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
{['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'].map(color => (
|
||
<button
|
||
key={color}
|
||
className="h-7 w-7 rounded-full border-2 border-white shadow-sm hover:scale-110 transition-transform active:scale-95"
|
||
style={{ backgroundColor: color }}
|
||
onPointerDown={(e) => {
|
||
e.preventDefault();
|
||
addTextHighlight({
|
||
book: readerBookAbbrev,
|
||
chapter: readerChapter,
|
||
verse: selectionPicker.verse,
|
||
startOffset: selectionPicker.startOffset,
|
||
endOffset: selectionPicker.endOffset,
|
||
color,
|
||
});
|
||
window.getSelection()?.removeAllRanges();
|
||
setSelectionPicker(null);
|
||
}}
|
||
/>
|
||
))}
|
||
<button
|
||
className="ml-1 h-7 w-7 rounded-full bg-slate-100 text-slate-500 text-xs flex items-center justify-center hover:bg-slate-200 transition"
|
||
onPointerDown={(e) => {
|
||
e.preventDefault();
|
||
window.getSelection()?.removeAllRanges();
|
||
setSelectionPicker(null);
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|