diff --git a/src/core/bible-reader.ts b/src/core/bible-reader.ts index 279ae44..5ad4b4c 100644 --- a/src/core/bible-reader.ts +++ b/src/core/bible-reader.ts @@ -171,12 +171,14 @@ export function formatPassageHTML( if (verseNewLine && p.verse > 0) { const num = showVerseNum ? `${p.verse} ` : ''; - html += `${num}${text.charAt(0).toUpperCase() + text.slice(1)}`; + html += `${num}${text.charAt(0).toUpperCase() + text.slice(1)}`; } else { + const vid = p.verse > 0 ? ` id="v${p.chapter}-${p.verse}"` : ''; if (showVerseNum && p.verse > 0) { - html += `${p.verse} `; + html += `${vid ? `` : ''}${p.verse} ${text} ${vid ? '' : ''}`; + } else { + html += text + ' '; } - html += text + ' '; } } diff --git a/src/core/i18n.ts b/src/core/i18n.ts index a0081a1..ba5da48 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -458,6 +458,9 @@ export interface Translations { noTextFound: string; notAvailableOffline: string; ensureDataConverted: string; + bookmarkAdd: string; + bookmarkEmpty: string; + bookmarkRename: string; }; // Settings @@ -1021,6 +1024,9 @@ const translations: Record = { noTextFound: 'No text found for this passage.', notAvailableOffline: 'Text not available for offline viewing.', ensureDataConverted: 'Bible text files are lazy-loaded on demand. Please ensure the data has been converted and is available.', + bookmarkAdd: 'Add bookmark', + bookmarkEmpty: 'No bookmarks yet', + bookmarkRename: 'Bookmark name:', }, settings: { @@ -1581,6 +1587,9 @@ const translations: Record = { noTextFound: 'Текст не найден для этого отрывка.', notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.', ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.', + bookmarkAdd: 'Добавить закладку', + bookmarkEmpty: 'Закладок пока нет', + bookmarkRename: 'Название закладки:', }, settings: { @@ -2141,6 +2150,9 @@ const translations: Record = { noTextFound: 'Текстъ не найденъ для сего отрывка.', notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.', ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.', + bookmarkAdd: 'Доба́вити закладкꙋ́', + bookmarkEmpty: 'Закладокъ пока̀ нетъ', + bookmarkRename: 'Зва́нiе закладки:', }, settings: { diff --git a/src/ui/bible-view.ts b/src/ui/bible-view.ts index 3c1a0b8..bb4de77 100644 --- a/src/ui/bible-view.ts +++ b/src/ui/bible-view.ts @@ -12,6 +12,40 @@ import { getTranslations, type LanguageCode } from '../core/i18n'; const SETTINGS_KEY = 'ponomar-settings'; const BIBLE_STATE_KEY = 'ponomar-bible-state'; +const BOOKMARKS_KEY = 'ponomar-bible-bookmarks'; + +interface BibleBookmark { + id: number; + name: string; + versionId: string; + bookId: string; + chapter: number; + verse?: number; + created: number; +} + +function loadBookmarks(): BibleBookmark[] { + try { + const raw = localStorage.getItem(BOOKMARKS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as BibleBookmark[]; + } + } catch { /* ignore */ } + return []; +} + +function saveBookmarks(bookmarks: BibleBookmark[]) { + try { + localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks)); + } catch { /* ignore */ } +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +let bookmarksOutsideClickInstalled = false; function loadBibleState(version: string): { book: string; passage: string } | null { try { @@ -81,12 +115,16 @@ export class BibleView { private loadedText: Map = new Map(); private t: ReturnType; private language: LanguageCode; + private bookmarks: BibleBookmark[]; + private bookmarksOpen = false; + private pendingScroll: { chapter: number; verse: number } | null = null; constructor(container: HTMLElement, language: LanguageCode = 'en', version = '', book = '', passage = '') { this.container = container; this.language = language; this.t = getTranslations(language); this.currentVersion = version || 'en/bible/kjv'; + this.bookmarks = loadBookmarks(); if (!book && !passage) { const saved = loadBibleState(this.currentVersion); @@ -148,6 +186,10 @@ export class BibleView { +
+ +
+
@@ -225,6 +267,165 @@ export class BibleView { } } }); + + document.getElementById('bookmark-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.bookmarksOpen = !this.bookmarksOpen; + const panel = document.getElementById('bookmark-panel'); + if (panel) { + if (this.bookmarksOpen) { + panel.classList.remove('hidden'); + panel.classList.add('flex', 'flex-col'); + } else { + panel.classList.add('hidden'); + panel.classList.remove('flex', 'flex-col'); + } + } + if (this.bookmarksOpen) this.renderBookmarkPanel(); + }); + + if (!bookmarksOutsideClickInstalled) { + bookmarksOutsideClickInstalled = true; + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.closest('#bookmark-panel') || target.closest('#bookmark-btn')) return; + const panel = document.getElementById('bookmark-panel'); + if (panel && !panel.classList.contains('hidden')) { + panel.classList.add('hidden'); + panel.classList.remove('flex', 'flex-col'); + } + }); + } + + this.renderBookmarkPanel(); + } + + private renderBookmarkPanel() { + const panel = document.getElementById('bookmark-panel'); + if (!panel) return; + const t = this.t.bible; + const rows = [...this.bookmarks].sort((a, b) => b.created - a.created).map(b => ` +
+ +
+ + +
+
+ `).join(''); + + panel.innerHTML = ` + + ${rows ? `
${rows}` : `

${t.bookmarkEmpty}

`} + `; + + document.getElementById('bookmark-add')?.addEventListener('click', () => { + this.addBookmarkFromCurrentPosition(); + }); + panel.querySelectorAll('.bookmark-jump').forEach(el => { + el.addEventListener('click', () => { + const id = parseInt((el as HTMLElement).getAttribute('data-id')!, 10); + const bm = this.bookmarks.find(x => x.id === id); + if (bm) this.jumpToBookmark(bm); + }); + }); + panel.querySelectorAll('.bookmark-rename').forEach(el => { + el.addEventListener('click', () => { + const id = parseInt((el as HTMLElement).getAttribute('data-id')!, 10); + this.renameBookmark(id); + }); + }); + panel.querySelectorAll('.bookmark-delete').forEach(el => { + el.addEventListener('click', () => { + const id = parseInt((el as HTMLElement).getAttribute('data-id')!, 10); + this.deleteBookmark(id); + }); + }); + } + + private versionShort(versionId: string): string { + const v = this.versions.find(x => x.id === versionId); + const name = v?.name ?? ''; + if (!name) return versionId.split('/').pop() || versionId; + const m = name.match(/\(([^)]+)\)\s*$/); + return m ? m[1] : name; + } + + private captureCurrentVerse(): number | undefined { + const textEl = document.getElementById('bible-text'); + if (!textEl) return undefined; + const containerTop = textEl.getBoundingClientRect().top; + let current: number | null = null; + textEl.querySelectorAll('[id^="v"]').forEach(el => { + const m = el.id.match(/^v(\d+)-(\d+)$/); + if (!m) return; + if (el.getBoundingClientRect().top <= containerTop + 8) { + current = parseInt(m[2], 10); + } + }); + return current ?? undefined; + } + + private addBookmarkFromCurrentPosition() { + const chapter = parseInt(this.currentPassage, 10) || 1; + const verse = this.captureCurrentVerse(); + const book = this.getBooksForVersion().find(b => b.id === this.currentBook); + const bookLabel = book?.name || this.currentBook; + const ref = verse ? `${bookLabel} ${chapter}:${verse}` : `${bookLabel} ${chapter}`; + const name = `${ref} (${this.versionShort(this.currentVersion)})`; + const bm: BibleBookmark = { + id: Date.now(), + name, + versionId: this.currentVersion, + bookId: this.currentBook, + chapter, + verse, + created: Date.now(), + }; + this.bookmarks.push(bm); + saveBookmarks(this.bookmarks); + this.renderBookmarkPanel(); + } + + private renameBookmark(id: number) { + const bm = this.bookmarks.find(x => x.id === id); + if (!bm) return; + const name = window.prompt(this.t.bible.bookmarkRename, bm.name); + if (name === null) return; + const trimmed = name.trim(); + if (trimmed) bm.name = trimmed; + saveBookmarks(this.bookmarks); + this.renderBookmarkPanel(); + } + + private deleteBookmark(id: number) { + this.bookmarks = this.bookmarks.filter(x => x.id !== id); + saveBookmarks(this.bookmarks); + this.renderBookmarkPanel(); + } + + private jumpToBookmark(bm: BibleBookmark) { + if (this.versions.some(v => v.id === bm.versionId)) { + this.currentVersion = bm.versionId; + const vs = document.getElementById('version-select') as HTMLSelectElement | null; + if (vs) vs.value = bm.versionId; + } + this.currentBook = bm.bookId; + this.updateBookSelect(); + this.currentPassage = String(bm.chapter); + this.updateChapterSelect(); + this.pendingScroll = bm.verse && bm.verse > 0 ? { chapter: bm.chapter, verse: bm.verse } : null; + this.loadReading(); + this.closeBookmarkPanel(); + } + + private closeBookmarkPanel() { + this.bookmarksOpen = false; + const panel = document.getElementById('bookmark-panel'); + if (panel) { + panel.classList.add('hidden'); + panel.classList.remove('flex', 'flex-col'); + } } private getBooksForVersion(): { id: string; name: string; chapters: number }[] { @@ -291,6 +492,15 @@ export class BibleView { textEl.className = `flex-1 overflow-auto p-2 max-w-3xl lg:max-w-4xl xl:max-w-5xl mx-auto liturgical-text ${fnClass}`; textEl.innerHTML = html || `

${this.t.bible.noTextFound}

`; + + if (this.pendingScroll) { + const target = this.pendingScroll; + this.pendingScroll = null; + requestAnimationFrame(() => { + const el = document.getElementById(`v${target.chapter}-${target.verse}`); + if (el) el.scrollIntoView({ block: 'start' }); + }); + } } catch { textEl.innerHTML = `