Stabilize Bible pages and add Strong's markers
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
- Attached the study margin and footnote area to the Bible page surface.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "libre-study",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"react": "^19.2.7",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"private": true,
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Libre Study",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"identifier": "org.librestudy.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+39
@@ -247,6 +247,45 @@ textarea:focus-visible {
|
||||
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,
|
||||
.notes-workspace {
|
||||
min-height: 0;
|
||||
|
||||
+105
-10
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Verse } from './types'
|
||||
import './App.css'
|
||||
|
||||
@@ -17,6 +18,7 @@ function App() {
|
||||
const [libraryVerses, setLibraryVerses] = useState<Verse[]>([])
|
||||
const [verses, setVerses] = useState<Verse[]>([])
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [pageStartId, setPageStartId] = useState<number | null>(null)
|
||||
const [selectedStrong, setSelectedStrong] = useState<string | null>(null)
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [noteDraft, setNoteDraft] = useState('')
|
||||
@@ -28,7 +30,9 @@ function App() {
|
||||
const state = await getLibraryState()
|
||||
setLibraryVerses(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) {
|
||||
@@ -37,7 +41,9 @@ function App() {
|
||||
setSelectedStrong(/^[gh]\d+$/i.test(trimmed) ? trimmed.toUpperCase() : null)
|
||||
const results = await searchVerses(nextQuery)
|
||||
setVerses(results)
|
||||
setSelectedId(results[0]?.id ?? null)
|
||||
const firstResult = results[0]
|
||||
setSelectedId(firstResult?.id ?? null)
|
||||
setPageStartId(firstResult ? findPageStartId(firstResult) : null)
|
||||
}
|
||||
|
||||
async function addTag() {
|
||||
@@ -76,13 +82,24 @@ function App() {
|
||||
selectedVerse.strongs[0]
|
||||
)
|
||||
}, [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(() => {
|
||||
if (!selectedVerse) return []
|
||||
const source = libraryVerses.length ? libraryVerses : verses
|
||||
const pageStartIndex = source.findIndex((verse) => verse.id === pageStartId)
|
||||
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)
|
||||
}, [libraryVerses, selectedVerse, verses])
|
||||
}, [libraryVerses, pageStartId, selectedVerse, verses])
|
||||
const pageRange = pageVerses.length
|
||||
? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}`
|
||||
: 'No page loaded'
|
||||
@@ -130,16 +147,94 @@ function App() {
|
||||
setSelectedStrong(null)
|
||||
setVerses(libraryVerses)
|
||||
setSelectedId(next.id)
|
||||
setPageStartId(findPageStartId(next))
|
||||
}
|
||||
|
||||
function shiftPage(direction: -1 | 1) {
|
||||
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 index = source.findIndex((verse) => verse.id === selectedVerse.id)
|
||||
setSelectedId(
|
||||
source[Math.min(source.length - 1, Math.max(0, index + direction * pageSize))]
|
||||
?.id ?? selectedVerse.id,
|
||||
const index = source.findIndex((entry) => entry.id === verse.id)
|
||||
if (index < 0) return pageStartId ?? verse.id
|
||||
const pageIndex = Math.floor(index / pageSize) * pageSize
|
||||
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') {
|
||||
@@ -156,10 +251,10 @@ function App() {
|
||||
<p
|
||||
key={verse.id}
|
||||
className={verse.id === selectedVerse.id ? 'page-verse selected' : 'page-verse'}
|
||||
onClick={() => setSelectedId(verse.id)}
|
||||
onClick={() => selectVerse(verse)}
|
||||
>
|
||||
<sup>{verse.verse}</sup>
|
||||
{verse.text}
|
||||
{renderVerseText(verse)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+6
-7
@@ -48,7 +48,6 @@ type BrowserStrongData = {
|
||||
|
||||
let browserLibraryCache: Promise<Verse[]> | null = null
|
||||
let browserStrongDataCache: Promise<BrowserStrongData> | null = null
|
||||
let browserStrongData: BrowserStrongData | null = null
|
||||
const browserVerseSourceIds = new Map<number, string>()
|
||||
|
||||
const fallbackBrowserVerses: Verse[] = [
|
||||
@@ -192,11 +191,14 @@ async function loadBrowserVerses() {
|
||||
})
|
||||
|
||||
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()
|
||||
return verses.map((verse) => ({
|
||||
...verse,
|
||||
strongs: browserStrongData?.linksByVerse.get(browserVerseKey(verse)) ?? verse.strongs,
|
||||
strongs: strongData?.linksByVerse.get(browserVerseKey(verse)) ?? verse.strongs,
|
||||
tags: tagsByVerse[verse.id] ?? [],
|
||||
}))
|
||||
}
|
||||
@@ -292,10 +294,7 @@ async function loadLibreBibleDevVerses(): Promise<Verse[]> {
|
||||
}
|
||||
|
||||
async function loadBrowserStrongData(): Promise<BrowserStrongData> {
|
||||
browserStrongDataCache ??= loadLibreBibleDevStrongData().then((data) => {
|
||||
browserStrongData = data
|
||||
return data
|
||||
})
|
||||
browserStrongDataCache ??= loadLibreBibleDevStrongData()
|
||||
|
||||
return browserStrongDataCache
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user