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:
1 parent
4b14b3fbe6
commit
bf02e071fa
8 files changed
+749
-771
No files matched your search
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user