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:
1 parent
b4ebd37cc3
commit
c652183a24
3 files changed
+227
-3
No files matched your search
@@ -171,12 +171,14 @@ export function formatPassageHTML(
|
|||||||
|
|
||||||
if (verseNewLine && p.verse > 0) {
|
if (verseNewLine && p.verse > 0) {
|
||||||
const num = showVerseNum ? `<sup class="text-red text-xs">${p.verse}</sup> ` : '';
|
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 {
|
} else {
|
||||||
|
const vid = p.verse > 0 ? ` id="v${p.chapter}-${p.verse}"` : '';
|
||||||
if (showVerseNum && p.verse > 0) {
|
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 + ' ';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -458,6 +458,9 @@ export interface Translations {
|
|||||||
noTextFound: string;
|
noTextFound: string;
|
||||||
notAvailableOffline: string;
|
notAvailableOffline: string;
|
||||||
ensureDataConverted: string;
|
ensureDataConverted: string;
|
||||||
|
bookmarkAdd: string;
|
||||||
|
bookmarkEmpty: string;
|
||||||
|
bookmarkRename: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -1021,6 +1024,9 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
noTextFound: 'No text found for this passage.',
|
noTextFound: 'No text found for this passage.',
|
||||||
notAvailableOffline: 'Text not available for offline viewing.',
|
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.',
|
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: {
|
settings: {
|
||||||
@@ -1581,6 +1587,9 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
noTextFound: 'Текст не найден для этого отрывка.',
|
noTextFound: 'Текст не найден для этого отрывка.',
|
||||||
notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.',
|
notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.',
|
||||||
ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.',
|
ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.',
|
||||||
|
bookmarkAdd: 'Добавить закладку',
|
||||||
|
bookmarkEmpty: 'Закладок пока нет',
|
||||||
|
bookmarkRename: 'Название закладки:',
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
@@ -2141,6 +2150,9 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
noTextFound: 'Текстъ не найденъ для сего отрывка.',
|
noTextFound: 'Текстъ не найденъ для сего отрывка.',
|
||||||
notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.',
|
notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.',
|
||||||
ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.',
|
ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.',
|
||||||
|
bookmarkAdd: 'Доба́вити закладкꙋ́',
|
||||||
|
bookmarkEmpty: 'Закладокъ пока̀ нетъ',
|
||||||
|
bookmarkRename: 'Зва́нiе закладки:',
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -12,6 +12,40 @@ import { getTranslations, type LanguageCode } from '../core/i18n';
|
|||||||
|
|
||||||
const SETTINGS_KEY = 'ponomar-settings';
|
const SETTINGS_KEY = 'ponomar-settings';
|
||||||
const BIBLE_STATE_KEY = 'ponomar-bible-state';
|
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, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
let bookmarksOutsideClickInstalled = false;
|
||||||
|
|
||||||
function loadBibleState(version: string): { book: string; passage: string } | null {
|
function loadBibleState(version: string): { book: string; passage: string } | null {
|
||||||
try {
|
try {
|
||||||
@@ -81,12 +115,16 @@ export class BibleView {
|
|||||||
private loadedText: Map<string, string> = new Map();
|
private loadedText: Map<string, string> = new Map();
|
||||||
private t: ReturnType<typeof getTranslations>;
|
private t: ReturnType<typeof getTranslations>;
|
||||||
private language: LanguageCode;
|
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 = '') {
|
constructor(container: HTMLElement, language: LanguageCode = 'en', version = '', book = '', passage = '') {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.language = language;
|
this.language = language;
|
||||||
this.t = getTranslations(language);
|
this.t = getTranslations(language);
|
||||||
this.currentVersion = version || 'en/bible/kjv';
|
this.currentVersion = version || 'en/bible/kjv';
|
||||||
|
this.bookmarks = loadBookmarks();
|
||||||
|
|
||||||
if (!book && !passage) {
|
if (!book && !passage) {
|
||||||
const saved = loadBibleState(this.currentVersion);
|
const saved = loadBibleState(this.currentVersion);
|
||||||
@@ -148,6 +186,10 @@ export class BibleView {
|
|||||||
</select>
|
</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>
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<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">
|
<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 }[] {
|
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.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>`;
|
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 {
|
} catch {
|
||||||
textEl.innerHTML = `
|
textEl.innerHTML = `
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
|
|||||||
Reference in new issue
Block a user