Stabilize Bible pages and add Strong's markers
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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,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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user