Compare commits

...
10 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 4.6 2106595976 Show passage text in inline Ink Notes canvas
Passes selectedChunkVerses as headerContent to the inline DrawCanvas so
highlights overlay the actual scripture text, matching annotate mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 10:40:47 -04:00
nmemmertandClaude Sonnet 4.6 902fb5da02 Add Apple Pencil support, reading plan, full-text search, and inline ink
- Apple Pencil hover cursor: previews brush size/shape before contact
- Tilt shading: tilted pen strokes widen and reduce thinning via tiltX/Y
- Two-finger undo in draw mode (non-stylus two-finger tap)
- Copy & open Claude buttons: copies study/podcast prompt then opens claude.ai/new
- Inline ink canvas in study notes (DrawCanvas embedded in Notes tab)
- Full-text search across all project notes with 300ms debounce
- Reading plan: set a book + week goal, mark chapters read from the reader

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 10:15:45 -04:00
nmemmertandClaude Sonnet 4.6 837b81b038 Add page-flip reader mode — fixed-height CSS-columns panel
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>
2026-08-13 16:38:25 -04:00
nmemmertandClaude Sonnet 4.6 708f293939 Auto-hide toolbar in wide reader mode with focus bar restore
Wide mode now enters focus state automatically: the full toolbar collapses
and a slim frosted bar appears at the top showing the current chapter name,
Prev/Next buttons, and a ⚙ Tools button to restore the toolbar. A ✕ Hide
button inside the toolbar lets you re-enter focus mode without toggling wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:21:08 -04:00
nmemmertandClaude Sonnet 4.6 f2de985190 Add reader navigation: swipe, keyboard, side arrows, and wide layout
- Swipe left/right on mobile to go between chapters (60px threshold,
  ignores vertical-dominant gestures so scrolling still works)
- Left/right arrow keys on desktop navigate chapters (skips inputs)
- Fixed side ‹ › buttons at viewport midpoint for one-tap chapter nav;
  disappear when at the first/last chapter, hidden in draw mode
- Wide toggle (⊞) switches between narrow reading column (max-w-3xl)
  and full-width two-column layout (max-w-7xl, md:columns-2); persisted
  to localStorage so the preference survives page reloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:17:41 -04:00
nmemmertandClaude Sonnet 4.6 90fd5c4e9e Add fixed bottom chunk nav and IndexedDB Bible search cache
- Replace non-functional sticky nav with a fixed bottom bar (position: fixed)
  that stays visible while scrolling long study notes; hides in draw mode
- Add pb-20 to main so the bar never covers the last study section
- Cache whole-Bible search index (BSB complete.json, ~7MB) in IndexedDB
  with a 7-day TTL so repeated searches skip the network fetch entirely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:08:25 -04:00
nmemmertandClaude Sonnet 4.6 22ad12e677 Add text highlighting in reader — select text to color, visible in read and draw mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 15:03:08 -04:00
nmemmertandClaude Sonnet 4.6 b4ccc15f98 Add XS pen size and make it the default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:43:19 -04:00
nmemmertandClaude Sonnet 4.6 1a586db785 Sync reader draw ink to server for cross-device persistence
Previously reader ink was localStorage-only so it never followed the
user to a different browser or device.

- New reader_ink table (user_id, book_abbrev, chapter, strokes JSON)
- GET /api/reader/ink  — loads all pages for the signed-in user
- PUT /api/reader/ink/:book/:chapter — upserts one page
- On startup, client fetches server ink and merges it over localStorage
  (server wins so the most-recently saved version always wins)
- updateReaderPageInk debounces a PUT call 1 s after the last stroke,
  so rapid Apple Pencil strokes don't flood the server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 12:16:21 -04:00
nmemmertandClaude Sonnet 4.6 5668686f59 Fix PWA header buttons unreachable on iPad
black-translucent status bar overlays page content, pushing the top
nav buttons behind the status bar where they can't be tapped. Switch
to black (status bar takes its own space, content starts below).

Also add env(safe-area-inset-top) padding on <header> elements when
running in standalone/PWA mode so notched iPhones are handled too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 11:46:35 -04:00
12 changed files with 1162 additions and 173 deletions

No files matched your search

+7
View File
@@ -8,6 +8,13 @@
"port": 5173,
"autoPort": false
},
{
"name": "study-app-alt",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
"port": 5174,
"autoPort": false
},
{
"name": "study-app-server",
"runtimeExecutable": "npm",
-2
View File
@@ -22,10 +22,8 @@ _Refreshed 2026-07-06 (multiple passes) — items already shipped have been remo
## UX / UI
### Study Page
- **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back
### Reader
- **Whole-Bible search index isn't persisted** — the ~7MB `complete.json` fetch is cached in-memory only for the session; a page reload re-downloads it. Worth persisting to IndexedDB (not localStorage — too small) if this gets used often
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
### Home Page
+1 -1
View File
@@ -13,7 +13,7 @@
<!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Bible Study" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+36
View File
@@ -69,6 +69,18 @@ export function initDb() {
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// Reader ink — one row per user + book + chapter, stored as JSON
db.exec(`
CREATE TABLE IF NOT EXISTS reader_ink (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_abbrev TEXT NOT NULL,
chapter INTEGER NOT NULL,
strokes TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, book_abbrev, chapter)
);
`);
console.log(`SQLite database ready at ${DB_PATH}`);
}
@@ -146,6 +158,30 @@ export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
}
// ---------------------------------------------------------------------------
// Reader ink — cross-device sync for draw-mode annotations in the reader
// ---------------------------------------------------------------------------
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
export function getAllReaderInk(userId) {
const rows = db.prepare(
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
).all(userId);
return Object.fromEntries(
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
);
}
/** Upserts the ink strokes for one reader page. */
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
db.prepare(`
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, book_abbrev, chapter)
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
}
/**
* Assigns any pre-existing, unowned projects (from before multi-user support)
* to the given user. Intended to run once, right after the first account is created.
+29
View File
@@ -11,6 +11,7 @@ import {
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
import {
@@ -359,6 +360,34 @@ app.delete('/api/projects/:id', requireAuth, (req, res) => {
}
});
// ---------------------------------------------------------------------------
// Reader ink — cross-device draw annotations on Bible chapters
// ---------------------------------------------------------------------------
// GET /api/reader/ink — all saved ink pages for the current user
app.get('/api/reader/ink', requireAuth, (req, res) => {
try {
res.json(getAllReaderInk(req.session.userId));
} catch (err) {
console.error('GET /api/reader/ink error:', err);
res.status(500).json({ error: 'Failed to load reader ink.' });
}
});
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
try {
const { book, chapter } = req.params;
const { strokes } = req.body;
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
setReaderInkPage(req.session.userId, book, chapter, strokes);
res.json({ ok: true });
} catch (err) {
console.error('PUT /api/reader/ink error:', err);
res.status(500).json({ error: 'Failed to save reader ink.' });
}
});
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
+202 -9
View File
@@ -71,6 +71,18 @@ export const STUDY_TABS = [
{ id: 'script', label: 'Script' },
];
export const CHAPTER_COUNTS = {
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
'3JN':1,JUD:1,REV:22,
};
export const bookOptions = [
{ name: 'Genesis', abbrev: 'GEN' },
{ name: 'Exodus', abbrev: 'EXO' },
@@ -1072,6 +1084,56 @@ export function CrossRefChip({ label, onRemove, loadVerseText }) {
);
}
// ---------------------------------------------------------------------------
// IndexedDB helpers for whole-Bible search cache
// ---------------------------------------------------------------------------
const BIBLE_IDB_NAME = 'study-app-bible-index';
const BIBLE_IDB_STORE = 'bible-index';
const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
function openBibleIndexDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = () => reject(req.error);
});
}
async function getBibleIndexFromIDB(translation) {
try {
const db = await openBibleIndexDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
}
async function saveBibleIndexToIDB(translation, flat) {
try {
const db = await openBibleIndexDB();
await new Promise((resolve, reject) => {
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch {
// Non-fatal; in-memory cache still works for this session
}
}
// ---------------------------------------------------------------------------
// App component
// ---------------------------------------------------------------------------
@@ -1157,6 +1219,11 @@ const App = () => {
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
const fullTextTimerRef = useRef(null);
const [readingPlan, setReadingPlan] = useState(() => {
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
});
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
@@ -1199,6 +1266,14 @@ const App = () => {
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
catch { return {}; }
});
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
return Array.isArray(val) ? val : [];
} catch { return []; }
});
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
const audioRef = useRef(null);
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
const audioBookRef = useRef(audioBook);
@@ -1319,7 +1394,7 @@ const App = () => {
); // 'stacked' | 'split' | 'annotate'
const [drawTool, setDrawTool] = useState('pen');
const [drawColor, setDrawColor] = useState('#0f172a');
const [drawSize, setDrawSize] = useState(0.012);
const [drawSize, setDrawSize] = useState(0.003);
const [activeStudyTab, setActiveStudyTab] = useState(
() => localStorage.getItem('activeStudyTab') || 'notes',
);
@@ -1332,6 +1407,39 @@ const App = () => {
useEffect(() => {
localStorage.setItem('activeStudyTab', activeStudyTab);
}, [activeStudyTab]);
// Full-text search across all project notes (debounced, synchronous localStorage read)
useEffect(() => {
clearTimeout(fullTextTimerRef.current);
const q = homeSearch.trim().toLowerCase();
if (q.length < 3) { setHomeFullTextResults(null); return; }
fullTextTimerRef.current = window.setTimeout(() => {
const index = loadProjectIndex();
const results = [];
for (const entry of index) {
const proj = loadProjectById(entry.id);
if (!proj) continue;
const matches = [];
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
for (const chunk of (ch.chunks ?? [])) {
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
const text = (chunk[field] ?? '').toLowerCase();
if (!text.includes(q)) continue;
const idx = text.indexOf(q);
const raw = chunk[field] ?? '';
const start = Math.max(0, idx - 40);
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
break; // one match per chunk
}
}
}
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
}
setHomeFullTextResults(results);
}, 300);
}, [homeSearch]);
const [verseSearch, setVerseSearch] = useState('');
const [collapsedSections, setCollapsedSections] = useState({});
const [commentarySource, setCommentarySource] = useState('matthew-henry');
@@ -1380,6 +1488,14 @@ const App = () => {
}
setBibleIndexStatus('loading');
try {
// Check IndexedDB cache before hitting the network (~7 MB fetch)
const cached = await getBibleIndexFromIDB('BSB');
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
_bibleIndexCacheRef.current.BSB = cached.flat;
setBibleIndexStatus('ready');
return;
}
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
const data = await res.json();
@@ -1396,6 +1512,8 @@ const App = () => {
}
_bibleIndexCacheRef.current.BSB = flat;
setBibleIndexStatus('ready');
// Persist for future visits — non-blocking
saveBibleIndexToIDB('BSB', flat);
} catch {
setBibleIndexStatus('error');
}
@@ -1558,6 +1676,15 @@ const App = () => {
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
return next;
});
// Debounce server sync — wait 1 s after last stroke before sending
clearTimeout(inkSaveTimerRef.current[key]);
inkSaveTimerRef.current[key] = setTimeout(() => {
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strokes }),
}).catch(() => {}); // silent — localStorage already saved locally
}, 1000);
}, []);
const cycleBookmarkColor = (verseKey) => {
@@ -1571,6 +1698,23 @@ const App = () => {
});
};
const addTextHighlight = (highlight) => {
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
setReaderTextHighlights(prev => {
const next = [...prev, { id, ...highlight }];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const removeTextHighlight = (id) => {
setReaderTextHighlights(prev => {
const next = prev.filter(h => h.id !== id);
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
if (_readerCrossRefCacheRef.current[cacheKey]) {
@@ -1610,7 +1754,21 @@ const App = () => {
getCurrentUser().then((result) => {
if (result.ok) {
setAuthUser(result.user);
if (result.user) setProjectIndex(switchStorageUser(result.user.id));
if (result.user) {
setProjectIndex(switchStorageUser(result.user.id));
// Load reader ink from the server; server wins over any stale localStorage copy
fetch('/api/reader/ink')
.then((r) => r.ok ? r.json() : null)
.then((serverInk) => {
if (!serverInk) return;
setReaderInkByPage((local) => {
const merged = { ...local, ...serverInk };
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
return merged;
});
})
.catch(() => {});
}
} else {
// Genuine network failure (server unreachable) — fall back to local-only mode.
setAuthServerDown(true);
@@ -3220,8 +3378,9 @@ const App = () => {
if (!project) return;
const prompt = buildClaudePrompt(project);
navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied for Claude!');
window.setTimeout(() => setSaveStatus(''), 2000);
setSaveStatus('Copied! Opening Claude…');
window.setTimeout(() => setSaveStatus(''), 3000);
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
});
};
@@ -3229,11 +3388,36 @@ const App = () => {
if (!project) return;
const prompt = buildPodcastPrompt(project, authUser?.podcastName);
navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied podcast prep!');
window.setTimeout(() => setSaveStatus(''), 2000);
setSaveStatus('Copied! Opening Claude…');
window.setTimeout(() => setSaveStatus(''), 3000);
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
});
};
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
setReadingPlan(plan);
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
};
const markChapterRead = (chapter) => {
setReadingPlan((prev) => {
if (!prev) return prev;
const chaptersRead = prev.chaptersRead.includes(chapter)
? prev.chaptersRead.filter((c) => c !== chapter)
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
const next = { ...prev, chaptersRead };
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
return next;
});
};
const clearReadingPlan = () => {
setReadingPlan(null);
try { localStorage.removeItem('readingPlan'); } catch {}
};
const copyPronunciationGuide = () => {
if (!project) return;
const guide = buildPronunciationGuide(project);
@@ -3521,17 +3705,18 @@ const deleteProject = (id) => {
onClick={copyForClaude}
disabled={allChunks.length === 0}
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
>
<span className="hidden sm:inline">Prepare for </span>Claude
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
</button>
<button
type="button"
onClick={copyForPodcast}
disabled={allChunks.length === 0}
title="Copy a prompt for Claude to write a full spoken-word episode script from your notes, ready to record. Optional — only useful if you're producing a podcast or similar audio series. Set your show name in Account Settings first."
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🎙<span className="hidden sm:inline"> Prepare for Podcast</span>
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
</button>
<button
type="button"
@@ -3700,6 +3885,11 @@ const deleteProject = (id) => {
homeSearch, setHomeSearch,
homeSort, setHomeSort,
homeTagFilter, setHomeTagFilter,
homeFullTextResults,
readingPlan,
createReadingPlan,
markChapterRead,
clearReadingPlan,
renamingId, setRenamingId,
renameValue, setRenameValue,
openBibleReader,
@@ -3747,6 +3937,9 @@ const deleteProject = (id) => {
readerAudioState,
readerInkByPage,
updateReaderPageInk,
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
bibleIndexStatus,
loadReaderChapter,
_bibleIndexCacheRef,
+7
View File
@@ -30,6 +30,13 @@ select {
font: inherit;
}
/* When installed as a PWA, push header content below the status bar */
@media all and (display-mode: standalone) {
header {
padding-top: env(safe-area-inset-top, 0px);
}
}
.scrollbar-thin::-webkit-scrollbar {
width: 9px;
height: 9px;
+72 -1
View File
@@ -4,6 +4,7 @@ import { renderInkToCanvas } from '../utils/inkRender.js';
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
const INK_SIZES = [
{ label: 'XS', value: 0.003 },
{ label: 'S', value: 0.006 },
{ label: 'M', value: 0.012 },
{ label: 'L', value: 0.025 },
@@ -38,6 +39,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
const { realStrokes } = extractMeta(strokes);
const canvasRef = useRef(null);
const hoverRef = useRef(null);
const activeStrokeRef = useRef([]);
const isDrawingRef = useRef(false);
@@ -78,14 +80,56 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
return () => obs.disconnect();
}, [render]);
// Hover cursor — reads refs so no stale-closure risk; no React re-renders on each move
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
function onMove(e) {
if (e.pointerType !== 'pen') return;
const el = hoverRef.current;
if (!el) return;
if (e.pressure > 0) { el.style.display = 'none'; return; }
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const sizePx = Math.max(6, drawSizeRef.current * rect.height);
const tool = drawToolRef.current;
const color = tool === 'eraser' ? '#94a3b8' : drawColorRef.current;
el.style.display = 'block';
el.style.width = `${sizePx}px`;
el.style.height = `${sizePx}px`;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.borderColor = color;
el.style.borderRadius = tool === 'eraser' ? '2px' : '50%';
}
function onLeave() {
if (hoverRef.current) hoverRef.current.style.display = 'none';
}
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerleave', onLeave);
return () => {
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerleave', onLeave);
};
}, []);
const getPoint = useCallback((e) => {
const canvas = canvasRef.current;
if (!canvas) return [0, 0, 0.5];
if (!canvas) return [0, 0, 0.5, 0];
const rect = canvas.getBoundingClientRect();
// tiltX/tiltY range ±90°; normalize to 0-1 where 1 = fully flat (60° threshold)
const tilt = e.pointerType === 'pen'
? Math.min(Math.hypot(e.tiltX || 0, e.tiltY || 0) / 60, 1)
: 0;
return [
(e.clientX - rect.left) / rect.width,
(e.clientY - rect.top) / rect.height,
e.pressure ?? 0.5,
tilt,
];
}, []);
@@ -132,17 +176,30 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
function pointFromTouch(t) {
const rect = canvas.getBoundingClientRect();
// radiusX grows as the pencil tilts flat; ~10px radius ≈ fully tilted
const tilt = Math.min((t.radiusX || 0) / 10, 1);
return [
(t.clientX - rect.left) / rect.width,
(t.clientY - rect.top) / rect.height,
t.force ?? 0.5,
tilt,
];
}
function tStart(e) {
// Two-finger tap (both non-stylus) = undo last stroke
if (e.touches.length === 2 && Array.from(e.touches).every((t) => t.touchType !== 'stylus')) {
e.preventDefault();
if (strokesRef.current.length > 0) {
onStrokesChangeRef.current(packStrokes(strokesRef.current.slice(0, -1), pageCountRef.current));
render();
}
return;
}
for (const t of e.changedTouches) {
if (t.touchType !== 'stylus') continue;
e.preventDefault();
if (hoverRef.current) hoverRef.current.style.display = 'none';
isDrawingRef.current = true;
activeStrokeRef.current = [pointFromTouch(t)];
render();
@@ -350,6 +407,20 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
{!headerContent && (
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
)}
{/* Hover cursor — positioned by the pointermove handler, never by React */}
<div
ref={hoverRef}
style={{
display: 'none',
position: 'absolute',
pointerEvents: 'none',
zIndex: 3,
border: '1.5px solid',
boxSizing: 'border-box',
transform: 'translate(-50%, -50%)',
opacity: 0.7,
}}
/>
{/* Single canvas spanning the entire area (scripture + notebook) */}
<canvas
ref={canvasRef}
+147 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { bookOptions, formatRelativeDate } from '../App.jsx';
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx';
export default function HomePage() {
const {
@@ -10,6 +11,10 @@ export default function HomePage() {
homeSearch, setHomeSearch,
homeSort, setHomeSort,
homeTagFilter, setHomeTagFilter,
homeFullTextResults,
readingPlan,
createReadingPlan,
clearReadingPlan,
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
@@ -145,6 +150,41 @@ export default function HomePage() {
);
})()}
</div>
{/* Full-text note search results */}
{homeFullTextResults !== null && (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
<p className="mb-3 text-sm font-semibold text-slate-700">
{homeFullTextResults.length === 0
? 'No notes match your search.'
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
</p>
<div className="space-y-4">
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
<div key={projectId}>
<button
type="button"
onClick={() => resumeProject(projectId)}
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
>
{projectTitle} →
</button>
<div className="space-y-1.5">
{matches.map(({ chunkId, ref, field, snippet }) => (
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Reading plan card */}
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
@@ -317,3 +357,109 @@ export default function HomePage() {
</div>
);
}
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) {
const [showForm, setShowForm] = useState(false);
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
const [planWeeks, setPlanWeeks] = useState(4);
if (readingPlan) {
const pct = readingPlan.totalChapters > 0
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
: 0;
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
return (
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
<p className="mt-0.5 text-sm text-slate-600">
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={openBibleReader}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Read
</button>
<button
type="button"
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
>
Clear
</button>
</div>
</div>
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
</div>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
{readingPlan.chaptersRead.length > 0 && (
<p className="mt-1 text-xs text-slate-500">
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''}
</p>
)}
</div>
);
}
return (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
{showForm ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-semibold text-slate-700">Read</span>
<select
value={planBook}
onChange={(e) => setPlanBook(e.target.value)}
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 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} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option>
))}
</select>
<span className="text-sm text-slate-600">in</span>
<select
value={planWeeks}
onChange={(e) => setPlanWeeks(Number(e.target.value))}
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)}
</select>
<button
type="button"
onClick={() => {
const book = bookOptions.find((b) => b.abbrev === planBook);
createReadingPlan(planBook, book?.name ?? planBook, planWeeks);
setShowForm(false);
}}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Start plan
</button>
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-700">Reading Plan</p>
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p>
</div>
<button
type="button"
onClick={() => setShowForm(true)}
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Set goal
</button>
</div>
)}
</div>
);
}
+577 -124
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from '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';
@@ -45,11 +45,180 @@ export default function ReaderPage() {
setReaderCrossRefs,
readerInkByPage,
updateReaderPageInk,
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
readingPlan,
markChapterRead,
} = 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('-');
@@ -84,7 +253,14 @@ export default function ReaderPage() {
</div>
</div>
</header>
<main className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : 'max-w-3xl'}`}>
<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">
@@ -112,6 +288,19 @@ export default function ReaderPage() {
</select>
</label>
<div className="ml-auto flex items-center gap-2">
{readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => {
const done = readingPlan.chaptersRead.includes(readerChapter);
return (
<button
type="button"
onClick={() => markChapterRead(readerChapter)}
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
title={done ? 'Click to unmark' : 'Mark this chapter as read in your reading plan'}
>
{done ? '✓ Read' : 'Mark as read'}
</button>
);
})()}
<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
@@ -216,8 +405,35 @@ export default function ReaderPage() {
>
✏ 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">
@@ -299,152 +515,299 @@ export default function ReaderPage() {
</div>
</div>
{/* Verses — hidden when draw mode is active */}
<div className={`rounded-3xl border border-slate-200 bg-white p-6 shadow-panel${readerDrawMode ? ' hidden' : ''}`}>
<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 &amp; 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="space-y-3 leading-relaxed text-slate-800" style={{ fontSize: `${readerFontSize}em` }}>
{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];
{/* 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 &amp; 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];
const highlightText = (text) => {
if (!searchLower) return text;
const idx = text.toLowerCase().indexOf(searchLower);
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="bg-yellow-200 rounded px-0.5">{text.slice(idx, idx + searchLower.length)}</mark>
{text.slice(idx + searchLower.length)}
</>
);
};
return (
<div key={verse.number} id={`reader-verse-${verse.number}`} className="group rounded-xl transition"
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">{highlightText(verse.text)}</p>
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
<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={() => 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} />
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>
{bmColor && (
<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={() => cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
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>
)}
<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>
);
})}
{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>
)}
{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');
{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 (
<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>
<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 &amp; 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>
)}
</div>
);
})}
</div>
);
})()}
</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">
<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">
<p key={v.number} className="mb-2" data-verse-num={v.number}>
<span className="font-semibold text-slate-700">{v.number}.</span>{' '}
{v.text}
{renderVerseText(v.number, v.text, '')}
</p>
))}
</div>
@@ -459,6 +822,96 @@ export default function ReaderPage() {
</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>
);
}
+69 -23
View File
@@ -76,7 +76,7 @@ export default function StudyPage() {
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
<main className="mx-auto max-w-7xl px-4 py-4 pb-20 sm:py-8 sm:pb-20 sm:px-6 lg:px-8">
<section className={`grid min-w-0 gap-6 ${studyLayout === 'split' ? '' : 'lg:grid-cols-[260px_1fr]'}`}>
{/* Sidebar */}
<aside className={`min-w-0 rounded-3xl border border-slate-200 bg-white p-4 sm:p-6 shadow-panel max-sm:hidden ${studyLayout === 'split' ? 'hidden' : ''}`}>
@@ -515,6 +515,47 @@ export default function StudyPage() {
</datalist>
</div>
{/* Inline ink notes — collapsible, always available without switching to annotate mode */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, inlineInk: !c.inlineInk }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Ink Notes</h3>
<p className="text-xs text-slate-500">Draw diagrams or handwritten notes for this chunk.</p>
</div>
<span className="text-slate-400">{collapsedSections.inlineInk ? '▸' : '▾'}</span>
</button>
{!collapsedSections.inlineInk && (
<div className="mt-4">
<DrawCanvas
strokes={selectedChunk.inkStrokes ?? []}
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
headerContent={
selectedChunkVerses.length > 0 && (
<>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
</span>
</div>
{selectedChunkVerses.map((verse) => (
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
{verse.text}
</p>
))}
</>
)
}
/>
</div>
)}
</div>
{/* Cross-references */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
<button
@@ -902,28 +943,6 @@ export default function StudyPage() {
</div>
)}
{/* Prev / Next — sticky */}
<div className="sticky bottom-0 z-10 -mx-4 sm:-mx-6 mt-6 flex items-center justify-between gap-3 border-t border-slate-200 bg-white/95 px-4 py-3 backdrop-blur-sm sm:px-6">
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
← Previous Chunk
</button>
<span className="text-xs text-slate-400">
{selectedChunkGlobalIndex + 1} / {allChunks.length}
</span>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next Chunk →
</button>
</div>
</div>
) : (
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
@@ -935,6 +954,33 @@ export default function StudyPage() {
</section>
</main>
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
{selectedChunk && studyLayout !== 'annotate' && (
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
← Previous Chunk
</button>
<span className="text-xs text-slate-400">
{selectedChunkGlobalIndex + 1} / {allChunks.length}
</span>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next Chunk →
</button>
</div>
</div>
)}
{/* Greek word picker modal */}
{suggestModal && createPortal(
<div
+15 -12
View File
@@ -33,14 +33,22 @@ export function renderInkToCanvas(
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
for (const s of strokes) {
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
const outline = getStroke(pts, {
size: s.size * h,
thinning: s.tool === 'highlighter' ? 0 : 0.5,
function strokeOpts(tool, baseSize, points) {
const avgTilt = points.length
? points.reduce((s, p) => s + (p[3] ?? 0), 0) / points.length
: 0;
return {
// Tilt widens the stroke and reduces pressure-thinning (pencil-shading feel)
size: tool === 'pen' ? baseSize * (1 + avgTilt * 2.5) : baseSize,
thinning: tool === 'highlighter' ? 0 : 0.5 * (1 - (tool === 'pen' ? avgTilt * 0.8 : 0)),
smoothing: 0.5,
streamline: 0.4,
});
};
}
for (const s of strokes) {
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
const outline = getStroke(pts, strokeOpts(s.tool, s.size * h, s.points));
ctx.globalAlpha = s.tool === 'highlighter' ? 0.35 : 1;
ctx.fillStyle = s.color;
ctx.fill(new Path2D(pathFromStroke(outline)));
@@ -48,12 +56,7 @@ export function renderInkToCanvas(
if (activeStroke.length > 1 && activeTool !== 'eraser') {
const pts = activeStroke.map(([x, y, p]) => [x * w, y * h, p]);
const outline = getStroke(pts, {
size: activeSize * h,
thinning: activeTool === 'highlighter' ? 0 : 0.5,
smoothing: 0.5,
streamline: 0.4,
});
const outline = getStroke(pts, strokeOpts(activeTool, activeSize * h, activeStroke));
ctx.globalAlpha = activeTool === 'highlighter' ? 0.35 : 1;
ctx.fillStyle = activeColor;
ctx.fill(new Path2D(pathFromStroke(outline)));