diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c8356..b2eeddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 65f53f8..93d9a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e9f6b7c..6bf6576 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 741069a..03905aa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.4" +version = "0.1.5" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e25f27d..6e7cc09 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.4" +version = "0.1.5" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d334f4b..0a7cacf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.css b/src/App.css index 2bfda4c..7594d6e 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index fab2b17..080849c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]) const [verses, setVerses] = useState([]) const [selectedId, setSelectedId] = useState(null) + const [pageStartId, setPageStartId] = useState(null) const [selectedStrong, setSelectedStrong] = useState(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( + { + event.stopPropagation() + selectStrong(verse, entry.strong) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + selectStrong(verse, entry.strong) + } + }} + > + {linkedText} + {(verse.id === selectedId || isActive) ? ( + {entry.strong} + ) : null} + , + ) + 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() {

setSelectedId(verse.id)} + onClick={() => selectVerse(verse)} > {verse.verse} - {verse.text} + {renderVerseText(verse)}

))} diff --git a/src/studyClient.ts b/src/studyClient.ts index c286941..3585fd4 100644 --- a/src/studyClient.ts +++ b/src/studyClient.ts @@ -48,7 +48,6 @@ type BrowserStrongData = { let browserLibraryCache: Promise | null = null let browserStrongDataCache: Promise | null = null -let browserStrongData: BrowserStrongData | null = null const browserVerseSourceIds = new Map() 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 { } async function loadBrowserStrongData(): Promise { - browserStrongDataCache ??= loadLibreBibleDevStrongData().then((data) => { - browserStrongData = data - return data - }) + browserStrongDataCache ??= loadLibreBibleDevStrongData() return browserStrongDataCache }