feat: Bible reader bookmarks

- Version-specific bookmarks (version/book/chapter/verse) in localStorage
- Toolbar 🔖 button with dropdown: add, jump, rename, delete
- Default names include version display name, e.g. 'John 3:16 (Елисаветинская)'
- Verse anchors id='v{chapter}-{verse}' for scroll-to-verse jumps
- Theme-safe panel styling (header-text on dropdown bg) with distinct
  tap zones: divider + chip buttons for edit/delete, touch press feedback
This commit is contained in:
catarrh committed 2026-08-22 20:24:02 +03:00
1 parent b4ebd37cc3
commit c652183a24
3 files changed
+227 -3

No files matched your search

+5 -3
View File
@@ -171,12 +171,14 @@ export function formatPassageHTML(
if (verseNewLine && p.verse > 0) {
const num = showVerseNum ? `<sup class="text-red text-xs">${p.verse}</sup> ` : '';
html += `<span class="block mt-1">${num}${text.charAt(0).toUpperCase() + text.slice(1)}</span>`;
html += `<span id="v${p.chapter}-${p.verse}" class="block mt-1">${num}${text.charAt(0).toUpperCase() + text.slice(1)}</span>`;
} else {
const vid = p.verse > 0 ? ` id="v${p.chapter}-${p.verse}"` : '';
if (showVerseNum && p.verse > 0) {
html += `<sup class="text-red text-xs">${p.verse}</sup> `;
html += `${vid ? `<span${vid}>` : ''}<sup class="text-red text-xs">${p.verse}</sup> ${text} ${vid ? '</span>' : ''}`;
} else {
html += text + ' ';
}
html += text + ' ';
}
}
+12
View File
@@ -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<LanguageCode, Translations> = {
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<LanguageCode, Translations> = {
noTextFound: 'Текст не найден для этого отрывка.',
notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.',
ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.',
bookmarkAdd: 'Добавить закладку',
bookmarkEmpty: 'Закладок пока нет',
bookmarkRename: 'Название закладки:',
},
settings: {
@@ -2141,6 +2150,9 @@ const translations: Record<LanguageCode, Translations> = {
noTextFound: 'Текстъ не найденъ для сего отрывка.',
notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.',
ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.',
bookmarkAdd: 'Доба́вити закладкꙋ́',
bookmarkEmpty: 'Закладокъ пока̀ нетъ',
bookmarkRename: 'Зва́нiе закладки:',
},
settings: {
+210
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
let bookmarksOutsideClickInstalled = false;
function loadBibleState(version: string): { book: string; passage: string } | null {
try {
@@ -81,12 +115,16 @@ export class BibleView {
private loadedText: Map<string, string> = new Map();
private t: ReturnType<typeof getTranslations>;
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 {
</select>
<button id="next-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">▶</button>
</div>
<div class="relative ml-auto">
<button id="bookmark-btn" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20" title="${this.t.bible.bookmarkAdd}">🔖</button>
<div id="bookmark-panel" class="${this.bookmarksOpen ? 'flex flex-col' : 'hidden'} absolute right-0 top-full mt-1 z-50 min-w-64 rounded-lg bg-dropdown-bg shadow-xl p-2 gap-1 text-header-text"></div>
</div>
</div>
<div id="bible-text" class="flex-1 overflow-auto p-2 max-w-3xl lg:max-w-4xl xl:max-w-5xl mx-auto liturgical-text">
@@ -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 => `
<div class="flex items-center rounded px-1 py-1 hover:bg-white/10">
<button class="bookmark-jump flex-1 min-w-0 text-left text-sm truncate cursor-pointer py-1 pr-2 rounded active:bg-white/15" data-id="${b.id}" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</button>
<div class="shrink-0 flex items-center gap-1 border-l border-gold/25 pl-2 ml-1">
<button class="bookmark-rename w-8 h-8 flex items-center justify-center text-base rounded-md bg-white/5 border border-white/15 active:bg-white/15 cursor-pointer" data-id="${b.id}">✏</button>
<button class="bookmark-delete w-8 h-8 flex items-center justify-center text-base rounded-md bg-white/5 border border-white/15 active:bg-white/15 cursor-pointer" data-id="${b.id}">✕</button>
</div>
</div>
`).join('');
panel.innerHTML = `
<button id="bookmark-add" class="w-full text-left bg-white/10 border border-gold/40 rounded px-2 py-1.5 text-sm font-bold hover:bg-white/20 cursor-pointer">+ ${t.bookmarkAdd}</button>
${rows ? `<div class="border-t border-gold/20 my-1"></div>${rows}` : `<p class="text-xs italic opacity-70 px-2 py-1">${t.bookmarkEmpty}</p>`}
`;
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<HTMLElement>('[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 || `<p class="text-navy-light">${this.t.bible.noTextFound}</p>`;
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 = `
<div class="text-center py-12">