Stabilize Bible pages and add Strong's markers

This commit is contained in:
2026-07-12 08:45:33 -05:00
parent e0b5ee9e58
commit c0774c8e52
9 changed files with 163 additions and 24 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.1.5 - 2026-07-12
- Fixed page selection so clicking a verse highlights it in place instead of rebuilding the page around it.
- Added session-stable page anchors for the loaded translation.
- Added inline Strong's highlights and code badges that connect Bible text to the study margin.
## 0.1.4 - 2026-07-12 ## 0.1.4 - 2026-07-12
- Attached the study margin and footnote area to the Bible page surface. - Attached the study margin and footnote area to the Bible page surface.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.4", "version": "0.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.4", "version": "0.1.5",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"react": "^19.2.7", "react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"log", "log",
"rusqlite", "rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.1.4" version = "0.1.5"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study", "productName": "Libre Study",
"version": "0.1.4", "version": "0.1.5",
"identifier": "org.librestudy.desktop", "identifier": "org.librestudy.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+39
View File
@@ -247,6 +247,45 @@ textarea:focus-visible {
font-weight: 800; font-weight: 800;
} }
.strong-token {
display: inline;
position: relative;
border-radius: 3px;
box-shadow: inset 0 -0.45em 0 rgba(214, 168, 79, 0.22);
cursor: pointer;
}
.strong-token:hover,
.strong-token:focus-visible {
box-shadow: inset 0 -1.1em 0 rgba(214, 168, 79, 0.34);
outline: none;
}
.strong-token.active {
box-shadow: inset 0 -1.1em 0 rgba(47, 111, 94, 0.3);
}
.strong-code {
display: inline-block;
margin: 0 2px 0 3px;
transform: translateY(-0.12em);
border: 1px solid rgba(138, 109, 46, 0.34);
border-radius: 4px;
padding: 0 3px;
background: #fff8e6;
color: #735c0f;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 9px;
font-weight: 800;
line-height: 1.35;
}
.strong-token.active .strong-code {
border-color: rgba(47, 111, 94, 0.42);
background: #e7f0eb;
color: #225446;
}
.study-margin, .study-margin,
.notes-workspace { .notes-workspace {
min-height: 0; min-height: 0;
+105 -10
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient' import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
import type { ReactNode } from 'react'
import type { Verse } from './types' import type { Verse } from './types'
import './App.css' import './App.css'
@@ -17,6 +18,7 @@ function App() {
const [libraryVerses, setLibraryVerses] = useState<Verse[]>([]) const [libraryVerses, setLibraryVerses] = useState<Verse[]>([])
const [verses, setVerses] = useState<Verse[]>([]) const [verses, setVerses] = useState<Verse[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [pageStartId, setPageStartId] = useState<number | null>(null)
const [selectedStrong, setSelectedStrong] = useState<string | null>(null) const [selectedStrong, setSelectedStrong] = useState<string | null>(null)
const [tagInput, setTagInput] = useState('') const [tagInput, setTagInput] = useState('')
const [noteDraft, setNoteDraft] = useState('') const [noteDraft, setNoteDraft] = useState('')
@@ -28,7 +30,9 @@ function App() {
const state = await getLibraryState() const state = await getLibraryState()
setLibraryVerses(state.verses) setLibraryVerses(state.verses)
setVerses(state.verses) setVerses(state.verses)
setSelectedId((current) => current ?? state.verses[0]?.id ?? null) const firstId = state.verses[0]?.id ?? null
setSelectedId((current) => current ?? firstId)
setPageStartId((current) => current ?? firstId)
} }
async function runSearch(nextQuery = query) { async function runSearch(nextQuery = query) {
@@ -37,7 +41,9 @@ function App() {
setSelectedStrong(/^[gh]\d+$/i.test(trimmed) ? trimmed.toUpperCase() : null) setSelectedStrong(/^[gh]\d+$/i.test(trimmed) ? trimmed.toUpperCase() : null)
const results = await searchVerses(nextQuery) const results = await searchVerses(nextQuery)
setVerses(results) setVerses(results)
setSelectedId(results[0]?.id ?? null) const firstResult = results[0]
setSelectedId(firstResult?.id ?? null)
setPageStartId(firstResult ? findPageStartId(firstResult) : null)
} }
async function addTag() { async function addTag() {
@@ -76,13 +82,24 @@ function App() {
selectedVerse.strongs[0] selectedVerse.strongs[0]
) )
}, [selectedStrong, selectedVerse]) }, [selectedStrong, selectedVerse])
const pageAnchors = useMemo(() => {
const source = libraryVerses.length ? libraryVerses : verses
const anchors: Verse[] = []
for (let index = 0; index < source.length; index += pageSize) {
anchors.push(source[index])
}
return anchors
}, [libraryVerses, verses])
const pageVerses = useMemo(() => { const pageVerses = useMemo(() => {
if (!selectedVerse) return [] if (!selectedVerse) return []
const source = libraryVerses.length ? libraryVerses : verses const source = libraryVerses.length ? libraryVerses : verses
const pageStartIndex = source.findIndex((verse) => verse.id === pageStartId)
const selectedIndex = source.findIndex((verse) => verse.id === selectedVerse.id) const selectedIndex = source.findIndex((verse) => verse.id === selectedVerse.id)
const start = Math.max(0, selectedIndex) const start = Math.max(0, pageStartIndex >= 0 ? pageStartIndex : selectedIndex)
return source.slice(start, start + pageSize) return source.slice(start, start + pageSize)
}, [libraryVerses, selectedVerse, verses]) }, [libraryVerses, pageStartId, selectedVerse, verses])
const pageRange = pageVerses.length const pageRange = pageVerses.length
? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}` ? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}`
: 'No page loaded' : 'No page loaded'
@@ -130,16 +147,94 @@ function App() {
setSelectedStrong(null) setSelectedStrong(null)
setVerses(libraryVerses) setVerses(libraryVerses)
setSelectedId(next.id) setSelectedId(next.id)
setPageStartId(findPageStartId(next))
} }
function shiftPage(direction: -1 | 1) { function shiftPage(direction: -1 | 1) {
if (!selectedVerse) return if (!selectedVerse) return
const currentStartIndex = pageAnchors.findIndex((verse) => verse.id === pageStartId)
const selectedPageStartId = findPageStartId(selectedVerse)
const selectedIndex = pageAnchors.findIndex((verse) => verse.id === selectedPageStartId)
const index = currentStartIndex >= 0 ? currentStartIndex : selectedIndex
const next =
pageAnchors[Math.min(pageAnchors.length - 1, Math.max(0, index + direction))]
if (!next) return
setPageStartId(next.id)
setSelectedId(next.id)
setSelectedStrong(null)
}
function findPageStartId(verse: Verse) {
const source = libraryVerses.length ? libraryVerses : verses const source = libraryVerses.length ? libraryVerses : verses
const index = source.findIndex((verse) => verse.id === selectedVerse.id) const index = source.findIndex((entry) => entry.id === verse.id)
setSelectedId( if (index < 0) return pageStartId ?? verse.id
source[Math.min(source.length - 1, Math.max(0, index + direction * pageSize))] const pageIndex = Math.floor(index / pageSize) * pageSize
?.id ?? selectedVerse.id, return source[pageIndex]?.id ?? verse.id
}
function selectVerse(verse: Verse) {
setSelectedId(verse.id)
setSelectedStrong(null)
}
function selectStrong(verse: Verse, strong: string) {
setSelectedId(verse.id)
setSelectedStrong(strong)
}
function renderVerseText(verse: Verse) {
if (!verse.strongs.length) return verse.text
const parts: ReactNode[] = []
let cursor = 0
const lowerText = verse.text.toLowerCase()
const sortedLinks = [...verse.strongs].sort((left, right) => left.position - right.position)
sortedLinks.forEach((entry) => {
const surface = entry.surface.trim()
if (!surface) return
const index = lowerText.indexOf(surface.toLowerCase(), cursor)
if (index < 0) return
if (index > cursor) {
parts.push(verse.text.slice(cursor, index))
}
const linkedText = verse.text.slice(index, index + surface.length)
const isActive = entry.strong === selectedStrong
parts.push(
<span
key={`${verse.id}-${entry.strong}-${entry.position}`}
className={isActive ? 'strong-token active' : 'strong-token'}
role="button"
tabIndex={0}
title={`${entry.surface}: ${entry.strong}`}
onClick={(event) => {
event.stopPropagation()
selectStrong(verse, entry.strong)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectStrong(verse, entry.strong)
}
}}
>
<span className="strong-surface">{linkedText}</span>
{(verse.id === selectedId || isActive) ? (
<small className="strong-code">{entry.strong}</small>
) : null}
</span>,
) )
cursor = index + surface.length
})
if (cursor < verse.text.length) {
parts.push(verse.text.slice(cursor))
}
return parts.length ? parts : verse.text
} }
function renderBiblePage(label = 'KJV') { function renderBiblePage(label = 'KJV') {
@@ -156,10 +251,10 @@ function App() {
<p <p
key={verse.id} key={verse.id}
className={verse.id === selectedVerse.id ? 'page-verse selected' : 'page-verse'} className={verse.id === selectedVerse.id ? 'page-verse selected' : 'page-verse'}
onClick={() => setSelectedId(verse.id)} onClick={() => selectVerse(verse)}
> >
<sup>{verse.verse}</sup> <sup>{verse.verse}</sup>
{verse.text} {renderVerseText(verse)}
</p> </p>
))} ))}
</div> </div>
+6 -7
View File
@@ -48,7 +48,6 @@ type BrowserStrongData = {
let browserLibraryCache: Promise<Verse[]> | null = null let browserLibraryCache: Promise<Verse[]> | null = null
let browserStrongDataCache: Promise<BrowserStrongData> | null = null let browserStrongDataCache: Promise<BrowserStrongData> | null = null
let browserStrongData: BrowserStrongData | null = null
const browserVerseSourceIds = new Map<number, string>() const browserVerseSourceIds = new Map<number, string>()
const fallbackBrowserVerses: Verse[] = [ const fallbackBrowserVerses: Verse[] = [
@@ -192,11 +191,14 @@ async function loadBrowserVerses() {
}) })
const verses = await browserLibraryCache const verses = await browserLibraryCache
void loadBrowserStrongData() const strongData = await loadBrowserStrongData().catch((error) => {
console.warn('Unable to load browser Strong data.', error)
return null
})
const tagsByVerse = readBrowserTags() const tagsByVerse = readBrowserTags()
return verses.map((verse) => ({ return verses.map((verse) => ({
...verse, ...verse,
strongs: browserStrongData?.linksByVerse.get(browserVerseKey(verse)) ?? verse.strongs, strongs: strongData?.linksByVerse.get(browserVerseKey(verse)) ?? verse.strongs,
tags: tagsByVerse[verse.id] ?? [], tags: tagsByVerse[verse.id] ?? [],
})) }))
} }
@@ -292,10 +294,7 @@ async function loadLibreBibleDevVerses(): Promise<Verse[]> {
} }
async function loadBrowserStrongData(): Promise<BrowserStrongData> { async function loadBrowserStrongData(): Promise<BrowserStrongData> {
browserStrongDataCache ??= loadLibreBibleDevStrongData().then((data) => { browserStrongDataCache ??= loadLibreBibleDevStrongData()
browserStrongData = data
return data
})
return browserStrongDataCache return browserStrongDataCache
} }