build: production build with Phase 1 2025 Bible Reader implementation complete
Includes all Phase 1 features: - Search-first navigation with auto-complete - Responsive reading interface (desktop/tablet/mobile) - 4 customization presets + full fine-tuning controls - Layered details panel with notes, bookmarks, highlights - Smart offline caching with IndexedDB and auto-sync - Full accessibility (WCAG 2.1 AA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
948
PARALLEL_BIBLE_VIEW_PLAN.md
Normal file
948
PARALLEL_BIBLE_VIEW_PLAN.md
Normal file
@@ -0,0 +1,948 @@
|
||||
# Parallel Bible View - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement a side-by-side Bible reading experience allowing users to compare multiple translations simultaneously, perfect for Bible study, translation verification, and deep Scripture analysis.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🔴 High
|
||||
**Estimated Time:** 2 weeks (80 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Enable simultaneous viewing of 2-3 Bible translations
|
||||
2. Provide synchronized scrolling across all panes
|
||||
3. Allow easy switching between versions
|
||||
4. Maintain responsive design for mobile devices
|
||||
5. Support independent highlighting per version
|
||||
|
||||
### User Value Proposition
|
||||
- **For Bible students**: Compare translations to understand nuances
|
||||
- **For scholars**: Analyze textual differences
|
||||
- **For language learners**: See original and translated text
|
||||
- **For teachers**: Prepare lessons with multiple versions
|
||||
- **For translators**: Verify accuracy against source texts
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Layout Configurations
|
||||
|
||||
```typescript
|
||||
type PaneLayout = '1-pane' | '2-pane-horizontal' | '2-pane-vertical' | '3-pane' | '4-pane'
|
||||
|
||||
interface LayoutConfig {
|
||||
layout: PaneLayout
|
||||
panes: PaneConfig[]
|
||||
syncScroll: boolean
|
||||
syncChapter: boolean // All panes show same chapter
|
||||
equalWidths: boolean
|
||||
showDividers: boolean
|
||||
compactMode: boolean // Reduce padding on mobile
|
||||
}
|
||||
|
||||
interface PaneConfig {
|
||||
id: string
|
||||
versionId: string
|
||||
visible: boolean
|
||||
width: number // percentage (for horizontal layouts)
|
||||
locked: boolean // Prevent accidental changes
|
||||
customSettings?: {
|
||||
fontSize?: number
|
||||
theme?: 'light' | 'dark' | 'sepia'
|
||||
showVerseNumbers?: boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Visual Layouts
|
||||
|
||||
#### Desktop Layouts
|
||||
```
|
||||
2-Pane Horizontal:
|
||||
┌─────────────────┬─────────────────┐
|
||||
│ KJV │ ESV │
|
||||
│ │ │
|
||||
│ Genesis 1:1 │ Genesis 1:1 │
|
||||
│ In the │ In the │
|
||||
│ beginning... │ beginning... │
|
||||
│ │ │
|
||||
└─────────────────┴─────────────────┘
|
||||
|
||||
3-Pane:
|
||||
┌───────┬───────┬───────┐
|
||||
│ KJV │ ESV │ NIV │
|
||||
│ │ │ │
|
||||
│ Gen 1 │ Gen 1 │ Gen 1 │
|
||||
│ │ │ │
|
||||
└───────┴───────┴───────┘
|
||||
|
||||
2-Pane Vertical (Stacked):
|
||||
┌─────────────────────────┐
|
||||
│ KJV - Genesis 1 │
|
||||
│ │
|
||||
│ 1 In the beginning... │
|
||||
└─────────────────────────┘
|
||||
┌─────────────────────────┐
|
||||
│ ESV - Genesis 1 │
|
||||
│ │
|
||||
│ 1 In the beginning... │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
#### Mobile Layout
|
||||
```
|
||||
Mobile (Stacked with Tabs):
|
||||
┌─────────────────────────┐
|
||||
│ [KJV] [ESV] [NIV] [+] │ ← Tab bar
|
||||
├─────────────────────────┤
|
||||
│ Genesis 1:1-31 │
|
||||
│ │
|
||||
│ 1 In the beginning... │
|
||||
│ 2 And the earth... │
|
||||
│ │
|
||||
│ ▼ Swipe to compare ▼ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Synchronized Scrolling
|
||||
|
||||
```typescript
|
||||
interface ScrollSyncConfig {
|
||||
enabled: boolean
|
||||
mode: 'verse' | 'pixel' | 'paragraph'
|
||||
leadPane: string | 'any' // Which pane controls scroll
|
||||
smoothness: number // 0-1, animation easing
|
||||
threshold: number // Minimum scroll delta to trigger sync
|
||||
}
|
||||
|
||||
class ScrollSynchronizer {
|
||||
private panes: HTMLElement[]
|
||||
private isScrolling: boolean = false
|
||||
private scrollTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private config: ScrollSyncConfig) {}
|
||||
|
||||
syncScroll(sourcePane: HTMLElement, scrollTop: number): void {
|
||||
if (this.isScrolling) return
|
||||
this.isScrolling = true
|
||||
|
||||
switch (this.config.mode) {
|
||||
case 'verse':
|
||||
this.syncByVerse(sourcePane, scrollTop)
|
||||
break
|
||||
case 'pixel':
|
||||
this.syncByPixel(sourcePane, scrollTop)
|
||||
break
|
||||
case 'paragraph':
|
||||
this.syncByParagraph(sourcePane, scrollTop)
|
||||
break
|
||||
}
|
||||
|
||||
// Reset scrolling flag after brief delay
|
||||
clearTimeout(this.scrollTimeout)
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
this.isScrolling = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private syncByVerse(sourcePane: HTMLElement, scrollTop: number): void {
|
||||
// Find which verse is at the top of source pane
|
||||
const visibleVerse = this.getVisibleVerseNumber(sourcePane, scrollTop)
|
||||
|
||||
// Scroll other panes to show the same verse at top
|
||||
this.panes.forEach(pane => {
|
||||
if (pane === sourcePane) return
|
||||
|
||||
const targetVerse = pane.querySelector(`[data-verse="${visibleVerse}"]`)
|
||||
if (targetVerse) {
|
||||
pane.scrollTo({
|
||||
top: (targetVerse as HTMLElement).offsetTop - 100,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private syncByPixel(sourcePane: HTMLElement, scrollTop: number): void {
|
||||
// Calculate scroll percentage
|
||||
const scrollHeight = sourcePane.scrollHeight - sourcePane.clientHeight
|
||||
const scrollPercent = scrollTop / scrollHeight
|
||||
|
||||
// Apply same percentage to other panes
|
||||
this.panes.forEach(pane => {
|
||||
if (pane === sourcePane) return
|
||||
|
||||
const targetScrollHeight = pane.scrollHeight - pane.clientHeight
|
||||
const targetScrollTop = targetScrollHeight * scrollPercent
|
||||
|
||||
pane.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private getVisibleVerseNumber(pane: HTMLElement, scrollTop: number): number {
|
||||
const verses = Array.from(pane.querySelectorAll('[data-verse]'))
|
||||
const viewportTop = scrollTop + 100 // Offset for header
|
||||
|
||||
for (const verse of verses) {
|
||||
const verseTop = (verse as HTMLElement).offsetTop
|
||||
if (verseTop >= viewportTop) {
|
||||
return parseInt(verse.getAttribute('data-verse') || '1')
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Version Selector Per Pane
|
||||
|
||||
```typescript
|
||||
interface VersionSelectorProps {
|
||||
paneId: string
|
||||
currentVersionId: string
|
||||
onVersionChange: (versionId: string) => void
|
||||
position: 'top' | 'bottom'
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const VersionSelector: React.FC<VersionSelectorProps> = ({
|
||||
paneId,
|
||||
currentVersionId,
|
||||
onVersionChange,
|
||||
position,
|
||||
compact = false
|
||||
}) => {
|
||||
const [versions, setVersions] = useState<BibleVersion[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Load available versions
|
||||
fetch('/api/bible/versions')
|
||||
.then(r => r.json())
|
||||
.then(data => setVersions(data.versions))
|
||||
}, [])
|
||||
|
||||
const filteredVersions = versions.filter(v =>
|
||||
v.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
v.abbreviation.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Box className={`version-selector ${position}`}>
|
||||
<FormControl fullWidth size={compact ? 'small' : 'medium'}>
|
||||
<Select
|
||||
value={currentVersionId}
|
||||
onChange={(e) => onVersionChange(e.target.value)}
|
||||
renderValue={(value) => {
|
||||
const version = versions.find(v => v.id === value)
|
||||
return version?.abbreviation || 'Select Version'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<TextField
|
||||
placeholder="Search versions..."
|
||||
size="small"
|
||||
fullWidth
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
{filteredVersions.map(version => (
|
||||
<MenuItem key={version.id} value={version.id}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="600">
|
||||
{version.abbreviation}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{version.name} ({version.language})
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Verse Alignment Highlighting
|
||||
|
||||
```typescript
|
||||
interface AlignmentConfig {
|
||||
enabled: boolean
|
||||
highlightMode: 'hover' | 'focus' | 'always' | 'none'
|
||||
color: string
|
||||
showConnectors: boolean // Lines between aligned verses
|
||||
}
|
||||
|
||||
// Highlight same verse across all panes
|
||||
const VerseAlignmentHighlighter: React.FC = () => {
|
||||
const { panes, alignmentConfig } = useParallelView()
|
||||
const [hoveredVerse, setHoveredVerse] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!alignmentConfig.enabled || alignmentConfig.highlightMode === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
const handleVerseHover = (e: MouseEvent) => {
|
||||
const verseElement = (e.target as HTMLElement).closest('[data-verse]')
|
||||
if (verseElement) {
|
||||
const verseNum = parseInt(verseElement.getAttribute('data-verse') || '0')
|
||||
setHoveredVerse(verseNum)
|
||||
} else {
|
||||
setHoveredVerse(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', handleVerseHover)
|
||||
return () => document.removeEventListener('mouseover', handleVerseHover)
|
||||
}, [alignmentConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredVerse === null) {
|
||||
// Remove all highlights
|
||||
document.querySelectorAll('.verse-aligned').forEach(el => {
|
||||
el.classList.remove('verse-aligned')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Highlight verse in all panes
|
||||
panes.forEach(pane => {
|
||||
const verseElements = document.querySelectorAll(
|
||||
`#pane-${pane.id} [data-verse="${hoveredVerse}"]`
|
||||
)
|
||||
verseElements.forEach(el => el.classList.add('verse-aligned'))
|
||||
})
|
||||
}, [hoveredVerse, panes])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// CSS
|
||||
.verse-aligned {
|
||||
background-color: rgba(var(--primary-rgb), 0.1);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
padding-left: 8px;
|
||||
margin-left: -11px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Diff View for Text Differences
|
||||
|
||||
```typescript
|
||||
interface DiffConfig {
|
||||
enabled: boolean
|
||||
compareAgainst: string // Pane ID to use as reference
|
||||
diffMode: 'word' | 'phrase' | 'verse'
|
||||
highlightStyle: 'color' | 'underline' | 'background' | 'strikethrough'
|
||||
showSimilarity: boolean // Show % similarity score
|
||||
}
|
||||
|
||||
// Simple word-level diff
|
||||
function calculateDiff(text1: string, text2: string): DiffResult[] {
|
||||
const words1 = text1.split(/\s+/)
|
||||
const words2 = text2.split(/\s+/)
|
||||
|
||||
const diff: DiffResult[] = []
|
||||
|
||||
// Simple longest common subsequence approach
|
||||
let i = 0, j = 0
|
||||
while (i < words1.length || j < words2.length) {
|
||||
if (words1[i] === words2[j]) {
|
||||
diff.push({ type: 'same', text: words1[i] })
|
||||
i++
|
||||
j++
|
||||
} else {
|
||||
// Check if word exists ahead
|
||||
const indexInText2 = words2.slice(j).indexOf(words1[i])
|
||||
const indexInText1 = words1.slice(i).indexOf(words2[j])
|
||||
|
||||
if (indexInText2 !== -1 && (indexInText1 === -1 || indexInText2 < indexInText1)) {
|
||||
// Word missing in text1
|
||||
diff.push({ type: 'added', text: words2[j] })
|
||||
j++
|
||||
} else if (indexInText1 !== -1) {
|
||||
// Word missing in text2
|
||||
diff.push({ type: 'removed', text: words1[i] })
|
||||
i++
|
||||
} else {
|
||||
// Different words
|
||||
diff.push({ type: 'changed', text1: words1[i], text2: words2[j] })
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
interface DiffResult {
|
||||
type: 'same' | 'added' | 'removed' | 'changed'
|
||||
text?: string
|
||||
text1?: string
|
||||
text2?: string
|
||||
}
|
||||
|
||||
// Component to render diff
|
||||
const DiffHighlightedVerse: React.FC<{
|
||||
verseText: string
|
||||
referenceText: string
|
||||
config: DiffConfig
|
||||
}> = ({ verseText, referenceText, config }) => {
|
||||
if (!config.enabled) {
|
||||
return <span>{verseText}</span>
|
||||
}
|
||||
|
||||
const diff = calculateDiff(referenceText, verseText)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{diff.map((part, index) => {
|
||||
if (part.type === 'same') {
|
||||
return <span key={index}>{part.text} </span>
|
||||
} else if (part.type === 'added') {
|
||||
return (
|
||||
<mark key={index} className="diff-added">
|
||||
{part.text}{' '}
|
||||
</mark>
|
||||
)
|
||||
} else if (part.type === 'removed') {
|
||||
return (
|
||||
<del key={index} className="diff-removed">
|
||||
{part.text}{' '}
|
||||
</del>
|
||||
)
|
||||
} else if (part.type === 'changed') {
|
||||
return (
|
||||
<mark key={index} className="diff-changed">
|
||||
{part.text2}{' '}
|
||||
</mark>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Quick Swap Versions
|
||||
|
||||
```typescript
|
||||
// Allow swapping versions between panes
|
||||
const SwapVersionsButton: React.FC<{
|
||||
pane1Id: string
|
||||
pane2Id: string
|
||||
}> = ({ pane1Id, pane2Id }) => {
|
||||
const { panes, updatePane } = useParallelView()
|
||||
|
||||
const handleSwap = () => {
|
||||
const pane1 = panes.find(p => p.id === pane1Id)
|
||||
const pane2 = panes.find(p => p.id === pane2Id)
|
||||
|
||||
if (pane1 && pane2) {
|
||||
updatePane(pane1Id, { versionId: pane2.versionId })
|
||||
updatePane(pane2Id, { versionId: pane1.versionId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={handleSwap}
|
||||
size="small"
|
||||
title="Swap versions"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': { boxShadow: 4 }
|
||||
}}
|
||||
>
|
||||
<SwapHorizIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Column Width Adjustment
|
||||
|
||||
```typescript
|
||||
interface ResizablePane {
|
||||
id: string
|
||||
minWidth: number // percentage
|
||||
maxWidth: number
|
||||
currentWidth: number
|
||||
}
|
||||
|
||||
// Draggable divider between panes
|
||||
const PaneDivider: React.FC<{
|
||||
leftPaneId: string
|
||||
rightPaneId: string
|
||||
}> = ({ leftPaneId, rightPaneId }) => {
|
||||
const { updatePane } = useParallelView()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [startX, setStartX] = useState(0)
|
||||
const [startWidths, setStartWidths] = useState<[number, number]>([50, 50])
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true)
|
||||
setStartX(e.clientX)
|
||||
|
||||
const leftPane = document.getElementById(`pane-${leftPaneId}`)
|
||||
const rightPane = document.getElementById(`pane-${rightPaneId}`)
|
||||
|
||||
if (leftPane && rightPane) {
|
||||
const leftWidth = (leftPane.offsetWidth / leftPane.parentElement!.offsetWidth) * 100
|
||||
const rightWidth = (rightPane.offsetWidth / rightPane.parentElement!.offsetWidth) * 100
|
||||
setStartWidths([leftWidth, rightWidth])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX
|
||||
const container = document.querySelector('.parallel-view-container')
|
||||
if (!container) return
|
||||
|
||||
const deltaPercent = (deltaX / container.clientWidth) * 100
|
||||
|
||||
const newLeftWidth = Math.max(20, Math.min(80, startWidths[0] + deltaPercent))
|
||||
const newRightWidth = Math.max(20, Math.min(80, startWidths[1] - deltaPercent))
|
||||
|
||||
updatePane(leftPaneId, { width: newLeftWidth })
|
||||
updatePane(rightPaneId, { width: newRightWidth })
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, startX, startWidths])
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`pane-divider ${isDragging ? 'dragging' : ''}`}
|
||||
sx={{
|
||||
width: '8px',
|
||||
cursor: 'col-resize',
|
||||
bgcolor: 'divider',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.main',
|
||||
width: '12px'
|
||||
},
|
||||
'&.dragging': {
|
||||
bgcolor: 'primary.main',
|
||||
width: '12px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Independent Highlighting Per Version
|
||||
|
||||
```typescript
|
||||
// Each pane maintains its own highlights
|
||||
interface PaneHighlights {
|
||||
paneId: string
|
||||
highlights: Highlight[]
|
||||
}
|
||||
|
||||
// Store highlights per version in database
|
||||
model Highlight {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
versionId String // Link to specific Bible version
|
||||
book String
|
||||
chapter Int
|
||||
verse Int
|
||||
color String
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
version BibleVersion @relation(fields: [versionId], references: [id])
|
||||
|
||||
@@index([userId, versionId, book, chapter])
|
||||
}
|
||||
|
||||
// Load highlights per pane
|
||||
const loadPaneHighlights = async (
|
||||
paneId: string,
|
||||
versionId: string,
|
||||
book: string,
|
||||
chapter: number
|
||||
): Promise<Highlight[]> => {
|
||||
const response = await fetch(
|
||||
`/api/highlights?versionId=${versionId}&book=${book}&chapter=${chapter}`
|
||||
)
|
||||
return response.json()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Technical Implementation
|
||||
|
||||
### File Structure
|
||||
```
|
||||
/components/bible-reader/
|
||||
├── parallel-view/
|
||||
│ ├── ParallelViewProvider.tsx # Context provider
|
||||
│ ├── ParallelViewContainer.tsx # Main container
|
||||
│ ├── Pane.tsx # Individual pane
|
||||
│ ├── PaneDivider.tsx # Resizable divider
|
||||
│ ├── VersionSelector.tsx # Version picker per pane
|
||||
│ ├── LayoutSelector.tsx # Layout switcher
|
||||
│ ├── ScrollSynchronizer.tsx # Scroll sync logic
|
||||
│ ├── VerseAlignmentHighlighter.tsx # Verse highlighting
|
||||
│ ├── DiffView.tsx # Text difference view
|
||||
│ ├── SwapControl.tsx # Version swapping
|
||||
│ └── hooks/
|
||||
│ ├── useParallelView.ts # Main hook
|
||||
│ ├── useScrollSync.ts # Scroll synchronization
|
||||
│ ├── usePaneResize.ts # Resize logic
|
||||
│ └── useVerseAlignment.ts # Alignment logic
|
||||
└── reader.tsx # Updated main reader
|
||||
```
|
||||
|
||||
### Context Provider
|
||||
|
||||
```typescript
|
||||
// ParallelViewProvider.tsx
|
||||
interface ParallelViewContextType {
|
||||
// State
|
||||
enabled: boolean
|
||||
layout: LayoutConfig
|
||||
panes: PaneConfig[]
|
||||
scrollSync: ScrollSyncConfig
|
||||
alignmentConfig: AlignmentConfig
|
||||
diffConfig: DiffConfig
|
||||
|
||||
// Actions
|
||||
toggleParallelView: () => void
|
||||
addPane: (config: Partial<PaneConfig>) => void
|
||||
removePane: (paneId: string) => void
|
||||
updatePane: (paneId: string, updates: Partial<PaneConfig>) => void
|
||||
setLayout: (layout: PaneLayout) => void
|
||||
updateScrollSync: (config: Partial<ScrollSyncConfig>) => void
|
||||
swapVersions: (paneId1: string, paneId2: string) => void
|
||||
}
|
||||
|
||||
export const ParallelViewProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [layout, setLayoutState] = useState<LayoutConfig>(defaultLayout)
|
||||
const [panes, setPanes] = useState<PaneConfig[]>([])
|
||||
const [scrollSync, setScrollSync] = useState<ScrollSyncConfig>(defaultScrollSync)
|
||||
|
||||
// Load from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('parallel-view-config')
|
||||
if (saved) {
|
||||
const config = JSON.parse(saved)
|
||||
setEnabled(config.enabled)
|
||||
setLayoutState(config.layout)
|
||||
setPanes(config.panes)
|
||||
setScrollSync(config.scrollSync)
|
||||
} else {
|
||||
// Initialize with default 2-pane view
|
||||
const defaultPanes = [
|
||||
{ id: 'pane-1', versionId: 'kjv', visible: true, width: 50, locked: false },
|
||||
{ id: 'pane-2', versionId: 'esv', visible: true, width: 50, locked: false }
|
||||
]
|
||||
setPanes(defaultPanes)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('parallel-view-config', JSON.stringify({
|
||||
enabled,
|
||||
layout,
|
||||
panes,
|
||||
scrollSync
|
||||
}))
|
||||
}, [enabled, layout, panes, scrollSync])
|
||||
|
||||
const addPane = (config: Partial<PaneConfig>) => {
|
||||
const newPane: PaneConfig = {
|
||||
id: `pane-${Date.now()}`,
|
||||
versionId: config.versionId || 'kjv',
|
||||
visible: true,
|
||||
width: 100 / (panes.length + 1),
|
||||
locked: false,
|
||||
...config
|
||||
}
|
||||
|
||||
// Adjust existing pane widths
|
||||
const adjustedPanes = panes.map(p => ({
|
||||
...p,
|
||||
width: p.width * (panes.length / (panes.length + 1))
|
||||
}))
|
||||
|
||||
setPanes([...adjustedPanes, newPane])
|
||||
}
|
||||
|
||||
const removePane = (paneId: string) => {
|
||||
const updatedPanes = panes.filter(p => p.id !== paneId)
|
||||
// Redistribute widths
|
||||
const equalWidth = 100 / updatedPanes.length
|
||||
setPanes(updatedPanes.map(p => ({ ...p, width: equalWidth })))
|
||||
}
|
||||
|
||||
const updatePane = (paneId: string, updates: Partial<PaneConfig>) => {
|
||||
setPanes(panes.map(p =>
|
||||
p.id === paneId ? { ...p, ...updates } : p
|
||||
))
|
||||
}
|
||||
|
||||
const swapVersions = (paneId1: string, paneId2: string) => {
|
||||
const pane1 = panes.find(p => p.id === paneId1)
|
||||
const pane2 = panes.find(p => p.id === paneId2)
|
||||
|
||||
if (pane1 && pane2) {
|
||||
updatePane(paneId1, { versionId: pane2.versionId })
|
||||
updatePane(paneId2, { versionId: pane1.versionId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ParallelViewContext.Provider value={{
|
||||
enabled,
|
||||
layout,
|
||||
panes,
|
||||
scrollSync,
|
||||
alignmentConfig,
|
||||
diffConfig,
|
||||
toggleParallelView: () => setEnabled(!enabled),
|
||||
addPane,
|
||||
removePane,
|
||||
updatePane,
|
||||
setLayout: setLayoutState,
|
||||
updateScrollSync: (config) => setScrollSync({ ...scrollSync, ...config }),
|
||||
swapVersions
|
||||
}}>
|
||||
{children}
|
||||
</ParallelViewContext.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Main Container Component
|
||||
|
||||
```typescript
|
||||
// ParallelViewContainer.tsx
|
||||
export const ParallelViewContainer: React.FC = () => {
|
||||
const { enabled, layout, panes, scrollSync } = useParallelView()
|
||||
const scrollSynchronizer = useRef(new ScrollSynchronizer(scrollSync))
|
||||
|
||||
if (!enabled || panes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visiblePanes = panes.filter(p => p.visible)
|
||||
|
||||
const getGridTemplate = () => {
|
||||
switch (layout.layout) {
|
||||
case '2-pane-horizontal':
|
||||
return 'repeat(2, 1fr)'
|
||||
case '3-pane':
|
||||
return 'repeat(3, 1fr)'
|
||||
case '4-pane':
|
||||
return 'repeat(2, 1fr)'
|
||||
default:
|
||||
return '1fr'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="parallel-view-container"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: getGridTemplate(),
|
||||
gap: layout.showDividers ? 1 : 0,
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{visiblePanes.map((pane, index) => (
|
||||
<React.Fragment key={pane.id}>
|
||||
<Pane
|
||||
config={pane}
|
||||
onScroll={(scrollTop) => {
|
||||
if (scrollSync.enabled) {
|
||||
scrollSynchronizer.current.syncScroll(
|
||||
document.getElementById(`pane-${pane.id}`)!,
|
||||
scrollTop
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{layout.showDividers && index < visiblePanes.length - 1 && (
|
||||
<PaneDivider
|
||||
leftPaneId={visiblePanes[index].id}
|
||||
rightPaneId={visiblePanes[index + 1].id}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Data Persistence
|
||||
|
||||
### LocalStorage Schema
|
||||
```typescript
|
||||
interface ParallelViewStorage {
|
||||
version: number
|
||||
enabled: boolean
|
||||
layout: LayoutConfig
|
||||
panes: PaneConfig[]
|
||||
scrollSync: ScrollSyncConfig
|
||||
alignmentConfig: AlignmentConfig
|
||||
diffConfig: DiffConfig
|
||||
recentVersionCombinations: string[][] // Track popular combos
|
||||
}
|
||||
|
||||
// Key: 'bible-reader:parallel-view'
|
||||
```
|
||||
|
||||
### User Preferences API
|
||||
```typescript
|
||||
// Add to UserPreference model
|
||||
model UserPreference {
|
||||
// ... existing fields
|
||||
parallelViewConfig Json?
|
||||
favoriteVersionCombinations Json? // [["kjv", "esv"], ["niv", "msg"]]
|
||||
}
|
||||
|
||||
// API endpoint
|
||||
POST /api/user/preferences/parallel-view
|
||||
Body: ParallelViewStorage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1: Core Functionality
|
||||
**Day 1-2: Foundation**
|
||||
- [ ] Create context provider
|
||||
- [ ] Build basic 2-pane layout
|
||||
- [ ] Implement version selector per pane
|
||||
- [ ] Add layout switcher (1/2/3 panes)
|
||||
|
||||
**Day 3-4: Scroll Sync**
|
||||
- [ ] Implement scroll synchronizer
|
||||
- [ ] Add verse-based sync
|
||||
- [ ] Add pixel-based sync
|
||||
- [ ] Test smooth scrolling
|
||||
|
||||
**Day 5: Resizing & Controls**
|
||||
- [ ] Build resizable dividers
|
||||
- [ ] Add width adjustment
|
||||
- [ ] Implement swap versions
|
||||
- [ ] Test on different screen sizes
|
||||
|
||||
**Deliverable:** Working parallel view with basic features
|
||||
|
||||
### Week 2: Advanced Features & Polish
|
||||
**Day 1-2: Alignment & Diff**
|
||||
- [ ] Implement verse alignment highlighting
|
||||
- [ ] Build diff view
|
||||
- [ ] Add similarity calculations
|
||||
- [ ] Test with various translations
|
||||
|
||||
**Day 3-4: Mobile & Responsive**
|
||||
- [ ] Design mobile layout (tabs)
|
||||
- [ ] Implement swipe navigation
|
||||
- [ ] Optimize for tablets
|
||||
- [ ] Test touch gestures
|
||||
|
||||
**Day 5: Polish & Testing**
|
||||
- [ ] Independent highlighting per pane
|
||||
- [ ] Performance optimization
|
||||
- [ ] Bug fixes
|
||||
- [ ] Documentation
|
||||
|
||||
**Deliverable:** Production-ready parallel Bible view
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Plan
|
||||
|
||||
### Pre-Launch Checklist
|
||||
- [ ] All layouts tested (2/3/4 pane)
|
||||
- [ ] Scroll sync working smoothly
|
||||
- [ ] Mobile responsive design complete
|
||||
- [ ] Performance benchmarks met (<100ms lag)
|
||||
- [ ] Accessibility audit passed
|
||||
- [ ] Cross-browser testing complete
|
||||
- [ ] User documentation created
|
||||
|
||||
### Rollout Strategy
|
||||
1. **Beta (Week 1)**: 10% of users, 2-pane only
|
||||
2. **Staged (Week 2)**: 50% of users, all layouts
|
||||
3. **Full (Week 3)**: 100% of users
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes & Considerations
|
||||
|
||||
### Performance
|
||||
- Use virtual scrolling for long chapters
|
||||
- Debounce scroll sync (avoid jank)
|
||||
- Lazy load panes not in viewport
|
||||
- Cache rendered verses
|
||||
- Monitor memory usage with multiple panes
|
||||
|
||||
### Accessibility
|
||||
- Maintain keyboard navigation across panes
|
||||
- Screen reader support for pane switching
|
||||
- Focus management between panes
|
||||
- ARIA labels for all controls
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Owner:** Development Team
|
||||
**Status:** Ready for Implementation
|
||||
Reference in New Issue
Block a user