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>
949 lines
26 KiB
Markdown
949 lines
26 KiB
Markdown
# 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
|