Move study editor state into StudyPage and hooks

- StudyPage owns layout/tabs/collapsed sections, tags, DOCX script
  import, chunk navigation, keyboard shortcuts, and the selected-chunk
  values derived from the project.
- useStudyResources: commentary + interlinear for the selected chunk's
  chapter. These now load only on the study page (they previously also
  fetched while on Setup).
- useCrossRefs: typed refs, dataset suggestions, verse text.
- useWordStudy: word entries, Strong's lookups, suggestion picker.
- Fix: study-page messages (word suggestions, DOCX import, lookups) set a
  status that only SetupPage displayed, so they were never seen. They
  now show as a toast on the study page.
- copyVerse moves into ReaderPage.
- Tests: toast, arrow-key navigation, cross-refs, tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Opus 5.5 committed 2026-09-22 14:59:22 -04:00
1 parent 4b14b3fbe6
commit bf02e071fa
8 files changed
+749 -771

No files matched your search

+5 -1
View File
@@ -7,6 +7,11 @@ import DrawCanvas from './DrawCanvas.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
// Copies "Book 3:16 BSB — text" to the clipboard.
const copyVerse = (bookName, chapterNum, verseNum, text) => {
navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} BSB — ${text}`);
};
export default function ReaderPage() {
const {
goHome,
@@ -42,7 +47,6 @@ export default function ReaderPage() {
handlePlayReaderAudio,
handleToggleReaderAudioPause,
handleStopBookAudio,
copyVerse,
setReaderCrossRefs,
readerInkByPage,
updateReaderPageInk,
+157 -32
View File
@@ -1,60 +1,180 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import DOMPurify from 'dompurify';
import { useApp } from '../context/AppContext.js';
import { useCrossRefs } from '../hooks/useCrossRefs.js';
import { useStudyResources } from '../hooks/useStudyResources.js';
import { useWordStudy } from '../hooks/useWordStudy.js';
import { buildGreekDefinitionHtml, externalLookupLinks, speakOriginalWord } from '../utils/wordStudy.js';
import ProjectHeader from '../components/ProjectHeader.jsx';
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
import {
STUDY_TABS,
COMMENTARY_OPTIONS,
NT_BOOK_NUMBER,
formatChunkReference,
getChunkVerseEntries,
CrossRefChip,
} from '../utils/bibleUtils.jsx';
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk } = useApp();
// Short-lived status line (word suggestions, DOCX import, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
const statusTimerRef = useRef(null);
const notify = (message, ms = 2000) => {
window.clearTimeout(statusTimerRef.current);
setStatusMessage(message);
statusTimerRef.current = window.setTimeout(() => setStatusMessage(''), ms);
};
useEffect(() => () => window.clearTimeout(statusTimerRef.current), []);
const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const selectedChunkChapterIndex = selectedChunk
? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id))
: -1;
const selectedChunkChapter = selectedChunkChapterIndex >= 0
? project.chapters[selectedChunkChapterIndex]
: null;
const selectedChunkVerses = selectedChunk
? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk)
: [];
const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId);
const [studyLayout, setStudyLayout] = useState(() => {
const saved = localStorage.getItem('studyLayout');
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
}); // 'stacked' | 'split'
const [activeStudyTab, setActiveStudyTab] = useState(
() => localStorage.getItem('activeStudyTab') || 'notes',
);
const [mobileStudyTab, setMobileStudyTab] = useState('scripture');
useEffect(() => {
localStorage.setItem('studyLayout', studyLayout);
}, [studyLayout]);
useEffect(() => {
localStorage.setItem('activeStudyTab', activeStudyTab);
}, [activeStudyTab]);
const [collapsedSections, setCollapsedSections] = useState({});
const {
project,
allChunks,
selectedChunk,
selectedChunkChapterIndex,
selectedChunkChapter,
selectedChunkVerses,
selectedChunkGlobalIndex,
studyLayout, setStudyLayout,
activeStudyTab, setActiveStudyTab,
mobileStudyTab, setMobileStudyTab,
collapsedSections, setCollapsedSections,
interlinearData,
interlinearLoading,
interlinearError,
commentarySource, setCommentarySource,
commentaryData,
commentaryLoading,
commentaryError,
interlinearData,
interlinearLoading,
interlinearError,
} = useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections });
const {
crossRefInput, setCrossRefInput,
suggestingCrossRefs,
crossRefSuggestions,
tagInput, setTagInput,
suggestModal, setSuggestModal,
suggestSelection, setSuggestSelection,
suggestingGreekForChunkId,
suggestingHebrewForChunkId,
projectIndex,
goToPreviousChunk,
goToNextChunk,
updateProject,
updateChunk,
addCrossRef,
removeCrossRef,
suggestCrossRefsForChunk,
addSuggestedCrossRef,
addTag,
removeTag,
loadVerseText,
} = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
const {
suggestingGreekForChunkId,
suggestingHebrewForChunkId,
suggestModal, setSuggestModal,
suggestSelection, setSuggestSelection,
updateChunkWord,
addGreekWord,
removeGreekWord,
updateChunkWord,
lookupWord,
suggestGreekWordsForChunk,
suggestHebrewWordsForChunk,
confirmSuggestWords,
loadVerseText,
importFinalScriptDocx,
formatChunkReference: _ignored,
} = useApp();
} = useWordStudy({ project, allChunks, updateProject, notify });
const [tagInput, setTagInput] = useState('');
const addTag = (chunkId) => {
const tag = tagInput.trim();
if (!tag) return;
const existing = selectedChunk?.tags ?? [];
if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) {
setTagInput('');
return;
}
updateChunk(chunkId, { tags: [...existing, tag] });
setTagInput('');
};
const removeTag = (chunkId, tag) => {
updateChunk(chunkId, {
tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag),
});
};
useEffect(() => {
setTagInput('');
}, [selectedChunk?.id]);
const importFinalScriptDocx = async (chunkId, file) => {
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
const { default: mammoth } = await import('mammoth');
const { value } = await mammoth.extractRawText({ arrayBuffer });
updateChunk(chunkId, { finalScript: value.trim() });
notify('Imported script from DOCX.', 1800);
} catch {
notify('Could not read that DOCX file.', 1800);
}
};
const goToPreviousChunk = () => {
if (selectedChunkGlobalIndex <= 0) return;
updateProject((current) => ({
...current,
selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id,
}));
};
const goToNextChunk = () => {
if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return;
updateProject((current) => ({
...current,
selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id,
}));
};
// Keyboard shortcuts: arrow keys navigate chunks on the study page, Esc closes modals
useEffect(() => {
const handleKeyDown = (event) => {
const tag = event.target?.tagName;
const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (suggestModal) {
setSuggestModal(null);
setSuggestSelection(new Set());
}
return;
}
if (isEditable || suggestModal) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
goToPreviousChunk();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
goToNextChunk();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [suggestModal, selectedChunkGlobalIndex, allChunks]);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
@@ -998,6 +1118,11 @@ export default function StudyPage() {
</div>,
document.body
)}
{statusMessage && (
<div role="status" className="fixed bottom-20 left-1/2 z-40 -translate-x-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-lg">
{statusMessage}
</div>
)}
</div>
);
}