Compact reader controls and margin layout

This commit is contained in:
2026-07-12 10:44:01 -05:00
parent 162fb06870
commit 123cb8dd98
7 changed files with 425 additions and 100 deletions
+264 -35
View File
@@ -6,7 +6,7 @@ import {
getLibraryState,
searchVerses,
} from './studyClient'
import type { DragEvent, ReactNode } from 'react'
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { CommentaryEntry, DictionaryEntry, Verse } from './types'
import './App.css'
@@ -219,6 +219,7 @@ function App() {
const [timelineModalOpen, setTimelineModalOpen] = useState(false)
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
const [notesDrawerOpen, setNotesDrawerOpen] = useState(false)
const [notesPinned, setNotesPinned] = useState(false)
const [pageMotion, setPageMotion] = useState<PageMotion>('idle')
const [viewMode, setViewMode] = useState<ViewMode>('notes')
const [query, setQuery] = useState('')
@@ -245,6 +246,12 @@ function App() {
const [definitionCollapsed, setDefinitionCollapsed] = useState(false)
const [commentaryCollapsed, setCommentaryCollapsed] = useState(false)
const [dictionaryCollapsed, setDictionaryCollapsed] = useState(false)
const [showOriginalLine, setShowOriginalLine] = useState(true)
const [showLinkedLine, setShowLinkedLine] = useState(true)
const [showDefinitionPanel, setShowDefinitionPanel] = useState(true)
const [showCommentaryPanel, setShowCommentaryPanel] = useState(true)
const [showDictionaryPanel, setShowDictionaryPanel] = useState(true)
const [splitPercent, setSplitPercent] = useState(50)
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
@@ -252,6 +259,8 @@ function App() {
const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('')
const pageMotionTimeout = useRef<number | null>(null)
const notesDragOpenTimeout = useRef<number | null>(null)
const readerRef = useRef<HTMLElement | null>(null)
async function loadLibrary() {
setError(null)
@@ -389,12 +398,30 @@ function App() {
originalCollapsed: boolean
linkedCollapsed: boolean
definitionCollapsed: boolean
commentaryCollapsed: boolean
dictionaryCollapsed: boolean
showOriginalLine: boolean
showLinkedLine: boolean
showDefinitionPanel: boolean
showCommentaryPanel: boolean
showDictionaryPanel: boolean
splitPercent: number
notesPinned: boolean
}>
setLemmaFontSize(parsed.lemmaFontSize ?? 17)
setLinkedFontSize(parsed.linkedFontSize ?? 15)
setOriginalCollapsed(parsed.originalCollapsed ?? false)
setLinkedCollapsed(parsed.linkedCollapsed ?? false)
setDefinitionCollapsed(parsed.definitionCollapsed ?? false)
setCommentaryCollapsed(parsed.commentaryCollapsed ?? false)
setDictionaryCollapsed(parsed.dictionaryCollapsed ?? false)
setShowOriginalLine(parsed.showOriginalLine ?? true)
setShowLinkedLine(parsed.showLinkedLine ?? true)
setShowDefinitionPanel(parsed.showDefinitionPanel ?? true)
setShowCommentaryPanel(parsed.showCommentaryPanel ?? true)
setShowDictionaryPanel(parsed.showDictionaryPanel ?? true)
setSplitPercent(parsed.splitPercent ?? 50)
setNotesPinned(parsed.notesPinned ?? false)
} catch {
localStorage.removeItem(studySettingsStorageKey)
}
@@ -457,9 +484,33 @@ function App() {
originalCollapsed,
linkedCollapsed,
definitionCollapsed,
commentaryCollapsed,
dictionaryCollapsed,
showOriginalLine,
showLinkedLine,
showDefinitionPanel,
showCommentaryPanel,
showDictionaryPanel,
splitPercent,
notesPinned,
}),
)
}, [definitionCollapsed, lemmaFontSize, linkedCollapsed, linkedFontSize, originalCollapsed])
}, [
commentaryCollapsed,
definitionCollapsed,
dictionaryCollapsed,
lemmaFontSize,
linkedCollapsed,
linkedFontSize,
notesPinned,
originalCollapsed,
showCommentaryPanel,
showDefinitionPanel,
showDictionaryPanel,
showLinkedLine,
showOriginalLine,
splitPercent,
])
useEffect(() => {
if (restoredSessionId.current === activeSessionId) return
@@ -724,6 +775,34 @@ function App() {
)
}
function recordDisplaySetting(label: string) {
recordTimeline({
type: 'setting',
label,
})
}
function beginSplitResize(event: ReactMouseEvent<HTMLDivElement>) {
event.preventDefault()
const reader = readerRef.current
if (!reader) return
const resize = (moveEvent: MouseEvent) => {
const rect = reader.getBoundingClientRect()
const next = ((moveEvent.clientX - rect.left) / rect.width) * 100
setSplitPercent(Math.min(68, Math.max(36, next)))
}
const stop = () => {
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', stop)
recordDisplaySetting('Adjusted Bible and study margin split')
}
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', stop, { once: true })
}
function updateNoteDraft(value: string) {
setNoteDraft(value)
patchActiveSession({ noteDraft: value })
@@ -739,7 +818,20 @@ function App() {
function startTextDrag(event: DragEvent<HTMLElement>, value: string) {
event.dataTransfer.setData('text/plain', value)
event.dataTransfer.effectAllowed = 'copy'
setNotesDrawerOpen(true)
}
function scheduleNotesDragOpen() {
if (notesPinned || notesDragOpenTimeout.current) return
notesDragOpenTimeout.current = window.setTimeout(() => {
setNotesDrawerOpen(true)
notesDragOpenTimeout.current = null
}, 550)
}
function clearNotesDragOpen() {
if (!notesDragOpenTimeout.current) return
window.clearTimeout(notesDragOpenTimeout.current)
notesDragOpenTimeout.current = null
}
function renameActiveSession(title: string) {
@@ -881,7 +973,7 @@ function App() {
}
function renderOriginalLanguageLine() {
if (!selectedVerse?.strongs.length) return null
if (!showOriginalLine || !selectedVerse?.strongs.length) return null
const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H'))
const direction = isHebrew ? 'rtl' : 'ltr'
@@ -891,11 +983,15 @@ function App() {
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => setOriginalCollapsed((current) => !current)}
onClick={() => {
setOriginalCollapsed((current) => !current)
recordDisplaySetting('Toggled original language accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOriginalCollapsed((current) => !current)
recordDisplaySetting('Toggled original language accordion')
}
}}
>
@@ -943,7 +1039,7 @@ function App() {
}
function renderLinkedEnglishLine() {
if (!selectedVerse?.strongs.length) return null
if (!showLinkedLine || !selectedVerse?.strongs.length) return null
return (
<section className="english-strong-line" aria-label="English Strong's line">
@@ -951,11 +1047,15 @@ function App() {
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => setLinkedCollapsed((current) => !current)}
onClick={() => {
setLinkedCollapsed((current) => !current)
recordDisplaySetting('Toggled English linked text accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setLinkedCollapsed((current) => !current)
recordDisplaySetting('Toggled English linked text accordion')
}
}}
>
@@ -1003,6 +1103,8 @@ function App() {
}
function renderCommentarySection() {
if (!showCommentaryPanel) return null
if (activeSession?.commentaryEnabled === false) {
return (
<article className="resource-card">
@@ -1021,11 +1123,15 @@ function App() {
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => setCommentaryCollapsed((current) => !current)}
onClick={() => {
setCommentaryCollapsed((current) => !current)
recordDisplaySetting('Toggled commentary accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setCommentaryCollapsed((current) => !current)
recordDisplaySetting('Toggled commentary accordion')
}
}}
>
@@ -1060,6 +1166,8 @@ function App() {
}
function renderDictionarySection() {
if (!showDictionaryPanel) return null
if (activeSession?.dictionaryEnabled === false) {
return (
<article className="resource-card">
@@ -1078,11 +1186,15 @@ function App() {
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => setDictionaryCollapsed((current) => !current)}
onClick={() => {
setDictionaryCollapsed((current) => !current)
recordDisplaySetting('Toggled dictionary accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDictionaryCollapsed((current) => !current)
recordDisplaySetting('Toggled dictionary accordion')
}
}}
>
@@ -1158,7 +1270,21 @@ function App() {
<button type="button" onClick={() => shiftPage(-1)}>
Previous page
</button>
<span>King James Version - {pageRange}</span>
<div className="page-position">
<span>{pageRange}</span>
<div className="chapter-tabs" aria-label="Chapter selector">
{chapters.map((chapter) => (
<button
key={chapter}
type="button"
className={chapter === selectedVerse?.chapter ? 'active' : ''}
onClick={() => selectedVerse && jumpTo(selectedVerse.book, chapter, 1)}
>
{chapter}
</button>
))}
</div>
</div>
<button type="button" onClick={() => shiftPage(1)}>
Next page
</button>
@@ -1182,20 +1308,24 @@ function App() {
{activeSession?.strongsEnabled === false ? (
<p className="resource-disabled">Strong's links are disabled for this session.</p>
) : (
<>
<div className="margin-resource-grid">
{renderOriginalLanguageLine()}
{renderLinkedEnglishLine()}
{selectedStrongEntry ? (
{showDefinitionPanel && selectedStrongEntry ? (
<article className="strong-focus" aria-label="Strong's definition">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => setDefinitionCollapsed((current) => !current)}
onClick={() => {
setDefinitionCollapsed((current) => !current)
recordDisplaySetting('Toggled Strong definition accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDefinitionCollapsed((current) => !current)
recordDisplaySetting('Toggled Strong definition accordion')
}
}}
>
@@ -1223,10 +1353,12 @@ function App() {
) : null}
</article>
) : null}
</>
</div>
)}
{renderCommentarySection()}
{renderDictionarySection()}
<div className="margin-resource-grid">
{renderCommentarySection()}
{renderDictionarySection()}
</div>
</aside>
)
}
@@ -1617,8 +1749,15 @@ function App() {
<label className="checkbox-row">
<input
type="checkbox"
checked={!originalCollapsed}
onChange={(event) => setOriginalCollapsed(!event.target.checked)}
checked={showOriginalLine}
onChange={(event) => {
setShowOriginalLine(event.target.checked)
recordDisplaySetting(
event.target.checked
? 'Show original language line'
: 'Hide original language line',
)
}}
/>
Show original language line
</label>
@@ -1640,8 +1779,13 @@ function App() {
<label className="checkbox-row">
<input
type="checkbox"
checked={!linkedCollapsed}
onChange={(event) => setLinkedCollapsed(!event.target.checked)}
checked={showLinkedLine}
onChange={(event) => {
setShowLinkedLine(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show English linked text' : 'Hide English linked text',
)
}}
/>
Show English linked text
</label>
@@ -1652,12 +1796,47 @@ function App() {
<label className="checkbox-row">
<input
type="checkbox"
checked={!definitionCollapsed}
onChange={(event) => setDefinitionCollapsed(!event.target.checked)}
checked={showDefinitionPanel}
onChange={(event) => {
setShowDefinitionPanel(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show Strong definition' : 'Hide Strong definition',
)
}}
/>
Show expanded definition
</label>
</section>
<section className="modal-section">
<h3>Reference Panels</h3>
<label className="checkbox-row">
<input
type="checkbox"
checked={showCommentaryPanel}
onChange={(event) => {
setShowCommentaryPanel(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show commentary panel' : 'Hide commentary panel',
)
}}
/>
Show commentary
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={showDictionaryPanel}
onChange={(event) => {
setShowDictionaryPanel(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show dictionary panel' : 'Hide dictionary panel',
)
}}
/>
Show dictionary
</label>
</section>
</div>
</section>
</div>
@@ -1669,32 +1848,58 @@ function App() {
return (
<aside
className={notesDrawerOpen ? 'notes-drawer open' : 'notes-drawer'}
className={[
'notes-drawer',
notesDrawerOpen || notesPinned ? 'open' : '',
notesPinned ? 'pinned' : '',
].filter(Boolean).join(' ')}
aria-label="Notes drawer"
onMouseEnter={() => setNotesDrawerOpen(true)}
onMouseLeave={() => setNotesDrawerOpen(false)}
onDragEnter={(event) => {
event.preventDefault()
setNotesDrawerOpen(true)
scheduleNotesDragOpen()
}}
onDragLeave={clearNotesDragOpen}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
clearNotesDragOpen()
appendNoteClip(event.dataTransfer.getData('text/plain'))
setNotesDrawerOpen(true)
}}
>
<div className="notes-drawer-grip" aria-hidden="true">
<button
type="button"
className="notes-drawer-grip"
aria-label={notesDrawerOpen || notesPinned ? 'Close notes drawer' : 'Open notes drawer'}
onClick={() => {
if (notesPinned) return
setNotesDrawerOpen((current) => !current)
}}
>
<span>N</span>
<span>O</span>
<span>T</span>
<span>E</span>
<span>S</span>
</div>
</button>
<div className="notes-workspace">
<header>
<span className="eyebrow">Notes</span>
<h2>Study Notes</h2>
<div>
<span className="eyebrow">Notes</span>
<h2>Study Notes</h2>
</div>
<button
type="button"
className="pin-notes"
onClick={() => {
const nextPinned = !notesPinned
setNotesPinned(nextPinned)
setNotesDrawerOpen(nextPinned)
recordDisplaySetting(nextPinned ? 'Pinned notes drawer' : 'Unpinned notes drawer')
}}
>
{notesPinned ? 'Unpin' : 'Pin'}
</button>
</header>
<textarea
value={noteDraft}
@@ -1751,8 +1956,16 @@ function App() {
)
}
const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr`
return (
<main className={railOpen ? 'app-shell menu-open' : 'app-shell'}>
<main
className={[
'app-shell',
railOpen ? 'menu-open' : '',
notesPinned ? 'notes-pinned' : '',
].filter(Boolean).join(' ')}
>
<aside
className="tool-rail"
aria-label="Main tools"
@@ -1792,14 +2005,17 @@ function App() {
</button>
</aside>
<section className="reader">
<section className="reader" ref={readerRef}>
{loading ? (
<div className="status-panel">Loading local library...</div>
) : error ? (
<div className="status-panel error">{error}</div>
) : selectedVerse ? (
<>
<header className="top-bar">
<header
className="top-bar"
style={{ gridTemplateColumns: splitTemplate }}
>
<div className="bible-top-tools">
<div className="page-controls" aria-label="Page selectors">
<label>
@@ -1885,12 +2101,18 @@ function App() {
<button type="submit">Go</button>
</form>
</div>
<div className="split-spacer" aria-hidden="true" />
<div className="study-top-tools" aria-label="Study margin tools">
<span>Study margin tools</span>
<button type="button" onClick={() => setSettingsModalOpen(true)}>
Margin settings
</button>
</div>
</header>
<div className={`study-desk ${viewMode}`}>
<div
className={`study-desk ${viewMode}`}
style={{ gridTemplateColumns: splitTemplate }}
>
{viewMode === 'compare' ? (
<div className="page-system compare-system" aria-label="Compare translations">
<div className="compare-pages">
@@ -1918,6 +2140,13 @@ function App() {
{renderPageTurner()}
{renderBookTabs()}
</div>
<div
className="split-resizer"
role="separator"
aria-label="Resize Bible and study margin"
aria-orientation="vertical"
onMouseDown={beginSplitResize}
/>
{renderStudyMargin()}
</>
)}