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>
This commit is contained in:
+89
-7
@@ -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' },
|
||||
@@ -1207,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);
|
||||
@@ -1390,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');
|
||||
@@ -3328,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');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3337,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);
|
||||
@@ -3629,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"
|
||||
@@ -3808,6 +3885,11 @@ const deleteProject = (id) => {
|
||||
homeSearch, setHomeSearch,
|
||||
homeSort, setHomeSort,
|
||||
homeTagFilter, setHomeTagFilter,
|
||||
homeFullTextResults,
|
||||
readingPlan,
|
||||
createReadingPlan,
|
||||
markChapterRead,
|
||||
clearReadingPlan,
|
||||
renamingId, setRenamingId,
|
||||
renameValue, setRenameValue,
|
||||
openBibleReader,
|
||||
|
||||
@@ -39,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);
|
||||
|
||||
@@ -79,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,
|
||||
];
|
||||
}, []);
|
||||
|
||||
@@ -133,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();
|
||||
@@ -351,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export default function ReaderPage() {
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
readingPlan,
|
||||
markChapterRead,
|
||||
} = useApp();
|
||||
|
||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||
@@ -286,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
|
||||
|
||||
@@ -515,6 +515,29 @@ 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 })}
|
||||
/>
|
||||
</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
|
||||
|
||||
+15
-12
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user