Added ability to bookmark images in the epub reader.

This commit is contained in:
Joseph Milazzo 2025-07-09 19:54:41 -05:00
parent 6340867ba0
commit f59d1de559
14 changed files with 292 additions and 114 deletions

View File

@ -16,6 +16,7 @@ using API.Extensions;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Hangfire;
using Kavita.Common;
@ -734,6 +735,7 @@ public class ReaderController : BaseApiController
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
@ -743,6 +745,39 @@ public class ReaderController : BaseApiController
return Ok();
}
/// <summary>
/// Creates a bookmark for an epub image
/// </summary>
/// <param name="bookmarkDto"></param>
/// <returns></returns>
[HttpPost("bookmark-epub")]
public async Task<ActionResult> BookmarkEpubPage(BookmarkDto bookmarkDto)
{
// Don't let user save past total pages.
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return new UnauthorizedResult();
if (!await _accountService.HasBookmarkPermission(user))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
var cachedFilePath = _cacheService.GetCachedFile(chapter);
var path = await _bookService.CopyImageToTempFromBook(chapter.Id, bookmarkDto, cachedFilePath);
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
return Ok();
}
/// <summary>
/// Removes a bookmarked page for a Chapter
/// </summary>

View File

@ -15,6 +15,10 @@ public sealed record BookmarkDto
[Required]
public int ChapterId { get; set; }
/// <summary>
/// Only applicable for Epubs
/// </summary>
public int ImageOffset { get; set; }
/// <summary>
/// This is only used when getting all bookmarks.
/// </summary>
public SeriesDto? Series { get; set; }

View File

@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250708204811_BookAnnotations")]
[Migration("20250710004325_BookAnnotations")]
partial class BookAnnotations
{
/// <inheritdoc />
@ -241,6 +241,9 @@ namespace API.Data.Migrations
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<int>("ImageOffset")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");

View File

@ -23,6 +23,13 @@ namespace API.Data.Migrations
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageOffset",
table: "AppUserBookmark",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "AppUserAnnotation",
columns: table => new
@ -87,6 +94,10 @@ namespace API.Data.Migrations
migrationBuilder.DropColumn(
name: "SelectedText",
table: "AppUserTableOfContent");
migrationBuilder.DropColumn(
name: "ImageOffset",
table: "AppUserBookmark");
}
}
}

View File

@ -238,6 +238,9 @@ namespace API.Data.Migrations
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<int>("ImageOffset")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");

View File

@ -19,7 +19,11 @@ public class AppUserBookmark : IEntityDate
/// Filename in the Bookmark Directory
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Only applicable for Epubs - handles multiple images on one page
/// </summary>
/// <remarks>0-based index of the image position on page</remarks>
public int ImageOffset { get; set; }
// Relationships
[JsonIgnore]

View File

@ -61,6 +61,7 @@ public interface IBookService
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath);
}
public partial class BookService : IBookService
@ -735,6 +736,7 @@ public partial class BookService : IBookService
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
{
// TODO: Refactor this to use the Async version
try
{
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
@ -1001,6 +1003,109 @@ public partial class BookService : IBookService
return ret;
}
public async Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath)
{
using var book = await EpubReader.OpenBookAsync(cachedBookPath, LenientBookReaderOptions);
var counter = 0;
var doc = new HtmlDocument { OptionFixNestedTags = true };
var bookPages = await book.GetReadingOrderAsync();
foreach (var contentFileRef in bookPages)
{
if (bookmarkDto.Page != counter || contentFileRef.ContentType != EpubContentType.XHTML_1_1)
{
counter++;
continue;
}
var content = await contentFileRef.ReadContentAsync();
doc.LoadHtml(content);
var images = doc.DocumentNode.SelectNodes("//img")
?? doc.DocumentNode.SelectNodes("//image");
if (images == null || images.Count == 0)
{
throw new KavitaException("No images found on the specified page");
}
if (bookmarkDto.ImageOffset >= images.Count)
{
throw new KavitaException($"Image index {bookmarkDto.ImageOffset} is out of range. Page has {images.Count} images");
}
var targetImage = images[bookmarkDto.ImageOffset];
// Get the image source attribute
string? srcAttributeName = null;
if (targetImage.Attributes["src"] != null)
{
srcAttributeName = "src";
}
else if (targetImage.Attributes["xlink:href"] != null)
{
srcAttributeName = "xlink:href";
}
if (string.IsNullOrEmpty(srcAttributeName))
{
throw new KavitaException("Image element does not have a valid source attribute");
}
var imageSource = targetImage.Attributes[srcAttributeName].Value;
// Clean and get the correct key for the image
var imageKey = CleanContentKeys(GetKeyForImage(book, imageSource));
// Check if it's an external URL
if (imageKey.StartsWith("http"))
{
throw new KavitaException("Cannot copy external images");
}
// Get the image file from the epub
if (!book.Content.Images.TryGetLocalFileRefByKey(imageKey, out var imageFile))
{
throw new KavitaException($"Image file not found in epub: {imageKey}");
}
// Read the image content
var imageContent = await imageFile.ReadContentAsBytesAsync();
// Determine file extension from the image key or content type
var extension = Path.GetExtension(imageKey);
if (string.IsNullOrEmpty(extension))
{
// Fallback to determining extension from content type
extension = imageFile.ContentType switch
{
EpubContentType.IMAGE_JPEG => ".jpg",
EpubContentType.IMAGE_PNG => ".png",
EpubContentType.IMAGE_GIF => ".gif",
EpubContentType.IMAGE_SVG => ".svg",
_ => ".png"
};
}
// Create temp directory for this chapter if it doesn't exist
var tempChapterDir = Path.Combine(_directoryService.TempDirectory, chapterId.ToString());
_directoryService.ExistOrCreate(tempChapterDir);
// Generate unique filename
var uniqueFilename = $"{Guid.NewGuid()}{extension}";
var tempFilePath = Path.Combine(tempChapterDir, uniqueFilename);
// Write the image to the temp file
await File.WriteAllBytesAsync(tempFilePath, imageContent);
return tempFilePath;
}
throw new KavitaException($"Page {bookmarkDto.Page} not found in epub");
}
/// <summary>
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
/// then null is returned. This expects only an epub file
@ -1153,6 +1258,7 @@ public partial class BookService : IBookService
RewriteAnchors(page, doc, mappings);
// TODO: Pass bookmarks here for state management
ScopeImages(doc, book, apiBase);
InjectImages(doc, book, apiBase);
@ -1160,8 +1266,10 @@ public partial class BookService : IBookService
// Inject PTOC Bookmark Icons
InjectPTOCBookmarks(doc, book, ptocBookmarks);
// Inject Annotations
InjectAnnotations(doc, book, annotations);
return PrepareFinalHtml(doc, body);
}
@ -1226,88 +1334,6 @@ public partial class BookService : IBookService
/// <returns></returns>
public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter)
{
// using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
// var mappings = await CreateKeyToPageMappingAsync(book);
//
// var navItems = await book.GetNavigationAsync();
// var chaptersList = new List<BookChapterItem>();
//
// if (navItems != null)
// {
// foreach (var navigationItem in navItems)
// {
// if (navigationItem.NestedItems.Count == 0)
// {
// CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
// continue;
// }
//
// var nestedChapters = new List<BookChapterItem>();
//
// foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
// {
// var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
// if (mappings.TryGetValue(key, out var mapping))
// {
// nestedChapters.Add(new BookChapterItem
// {
// Title = nestedChapter.Title,
// Page = mapping,
// Part = nestedChapter.Link?.Anchor ?? string.Empty,
// Children = []
// });
// }
// }
//
// CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
// }
// }
//
// if (chaptersList.Count != 0) return chaptersList;
// // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
// var tocPage = book.Content.Html.Local.Select(s => s.Key)
// .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
// k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
// if (string.IsNullOrEmpty(tocPage)) return chaptersList;
//
//
// // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
// if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
// var content = await file.ReadContentAsync();
//
// var doc = new HtmlDocument();
// doc.LoadHtml(content);
//
// // TODO: We may want to check if there is a toc.ncs file to better handle nested toc
// // We could do a fallback first with ol/lis
//
//
//
// var anchors = doc.DocumentNode.SelectNodes("//a");
// if (anchors == null) return chaptersList;
//
// foreach (var anchor in anchors)
// {
// if (!anchor.Attributes.Contains("href")) continue;
//
// var key = CoalesceKey(book, mappings, anchor.Attributes["href"].Value.Split("#")[0]);
//
// if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue;
// var part = string.Empty;
// if (anchor.Attributes["href"].Value.Contains('#'))
// {
// part = anchor.Attributes["href"].Value.Split("#")[1];
// }
// chaptersList.Add(new BookChapterItem
// {
// Title = anchor.InnerText,
// Page = mappings[key],
// Part = part,
// Children = []
// });
// }
//
// return chaptersList;
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
var mappings = await CreateKeyToPageMappingAsync(book);
@ -1318,7 +1344,7 @@ public partial class BookService : IBookService
{
foreach (var navigationItem in navItems)
{
var tocItem = CreateToCChapterRecursively(book, navigationItem, mappings);
var tocItem = CreateToCChapter(book, navigationItem, mappings);
if (tocItem != null)
{
chaptersList.Add(tocItem);
@ -1368,7 +1394,7 @@ public partial class BookService : IBookService
return chaptersList;
}
private BookChapterItem? CreateToCChapterRecursively(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
{
// Get the page mapping for the current navigation item
var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
@ -1384,7 +1410,7 @@ public partial class BookService : IBookService
{
foreach (var nestedItem in navigationItem.NestedItems)
{
var childItem = CreateToCChapterRecursively(book, nestedItem, mappings);
var childItem = CreateToCChapter(book, nestedItem, mappings);
if (childItem != null)
{
children.Add(childItem);
@ -1407,8 +1433,9 @@ public partial class BookService : IBookService
return null;
}
private static int CountParentDirectory(string path)
private static int CountParentDirectory(string? path)
{
if (string.IsNullOrEmpty(path)) return 0;
return ParentDirectoryRegex().Matches(path).Count;
}

View File

@ -9,6 +9,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using Hangfire;
using Kavita.Common;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -113,12 +114,17 @@ public class BookmarkService : IBookmarkService
/// <param name="bookmarkDto"></param>
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</param>
/// <returns>If the save to DB and copy was successful</returns>
public async Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
public async Task<bool> BookmarkPage(AppUser? userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
{
if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false;
if (userWithBookmarks?.Bookmarks == null)
{
throw new KavitaException("Bookmarks cannot be null!");
}
try
{
var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId);
var userBookmark = userWithBookmarks.Bookmarks
.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId && b.ImageOffset == bookmarkDto.ImageOffset);
if (userBookmark != null)
{
_logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page);
@ -137,6 +143,7 @@ public class BookmarkService : IBookmarkService
SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId,
FileName = Path.Join(targetFolderStem, fileInfo.Name),
ImageOffset = bookmarkDto.ImageOffset,
AppUserId = userWithBookmarks.Id
};
@ -170,7 +177,7 @@ public class BookmarkService : IBookmarkService
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
{
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page);
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page && x.ImageOffset == bookmarkDto.ImageOffset);
try
{
if (bookmarkToDelete != null)

View File

@ -342,9 +342,7 @@ public class CacheService : ICacheService
// Calculate what chapter the page belongs to
var path = GetCachePath(chapterId);
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions)
//.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles
.ToArray();
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions);
return GetPageFromFiles(files, page);
}

View File

@ -356,7 +356,7 @@ public class ReaderService : IReaderService
return page;
}
private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
private static int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
{
if (volume.IsSpecial())
{

View File

@ -1,10 +1,14 @@
import {Series} from "../series";
export interface PageBookmark {
id: number;
page: number;
seriesId: number;
volumeId: number;
chapterId: number;
series: Series;
id: number;
page: number;
seriesId: number;
volumeId: number;
chapterId: number;
/**
* Only present on epub-based Bookmarks
*/
imageOffset: number;
series: Series;
}

View File

@ -102,14 +102,19 @@ export class ReaderService {
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
}
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, xPath: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page, xPath});
}
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0) {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page, imageNumber});
}
bookmarkEpub(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark-epub', {seriesId, volumeId, chapterId, page, imageNumber});
}
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
}

View File

@ -355,9 +355,7 @@ $pagination-opacity: 0;
//$pagination-color: red;
//$pagination-opacity: 0.7;
.kavita-scale-width::after {
content: ' ';
}
.right {

View File

@ -62,6 +62,7 @@ import {ColumnLayoutClassPipe} from "../../_pipes/column-layout-class.pipe";
import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe";
import {ChapterService} from "../../../_services/chapter.service";
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
import {PageBookmark} from "../../../_models/readers/page-bookmark";
interface HistoryPoint {
@ -128,6 +129,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly epubMenuService = inject(EpubReaderMenuService);
protected readonly readerSettingsService = inject(EpubReaderSettingsService);
private readonly destroyRef = inject(DestroyRef);
protected readonly BookPageLayoutMode = BookPageLayoutMode;
protected readonly WritingStyle = WritingStyle;
@ -271,6 +273,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
});
imageBookmarks = model<PageBookmark[]>([]);
/**
* Anchors that map to the page number. When you click on one of these, we will load a given page up for the user.
*/
@ -309,9 +313,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
//writingStyle: WritingStyle = WritingStyle.Horizontal;
/**
* When the user is highlighting something, then we remove pagination
*/
@ -324,7 +325,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
private readonly destroyRef = inject(DestroyRef);
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
/**
@ -414,7 +414,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return this.pageNum() === 0 && (currentVirtualPage === 0);
}
get PageWidthForPagination() {
if (this.layoutMode() === BookPageLayoutMode.Default && this.writingStyle() === WritingStyle.Vertical && this.horizontalScrollbarNeeded) {
@ -646,6 +646,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.nextChapterPrefetched = false;
this.cdRef.markForCheck();
this.loadImageBookmarks();
this.bookService.getBookInfo(this.chapterId, true).subscribe(async (info) => {
if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) {
@ -796,6 +798,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return 0;
}
loadImageBookmarks() {
this.readerService.getBookmarks(this.chapterId).subscribe(res => {
this.imageBookmarks.set(res);
});
}
loadNextChapter() {
if (this.nextPageDisabled) { return; }
this.isLoading = true;
@ -969,11 +977,82 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
.then(() => {
this.setupPage(part, scrollTop);
this.updateImageSizes();
this.injectImageBookmarkIndicators();
});
}, 10);
});
}
/**
* Injects the new DOM needed to provide the bookmark functionality.
* We can't use a wrapper due to potential for styling issues.
*/
injectImageBookmarkIndicators() {
const imgs = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('img') ?? []);
const bookmarksForPage = (this.imageBookmarks() ?? []).filter(b => b.page === this.pageNum());
imgs.forEach((img, index) => {
if (img.nextElementSibling?.classList.contains('bookmark-overlay')) return;
const matchingBookmarks = bookmarksForPage.filter(b => b.imageOffset == index);
let hasBookmark = false;
if (matchingBookmarks.length > 0) {
hasBookmark = true;
}
const icon = document.createElement('div');
icon.className = 'bookmark-overlay ' + (hasBookmark ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
//icon.attributes.title = hasBookmark ? 'Unbookmark' : 'Bookmark';
icon.style.cssText = `
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.8);
color: white;
padding: 6px;
border-radius: 50%;
cursor: pointer;
z-index: 1000;
font-size: 16px;
pointer-events: auto;
`;
// Make parent relative if needed
const parent = img.parentElement;
if (parent == null) return;
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
parent.appendChild(icon);
icon.addEventListener('click', () => {
if (hasBookmark) {
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index).subscribe(bookmark => {
const newState = !hasBookmark;
icon.className = 'bookmark-overlay ' + (newState ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
hasBookmark = !hasBookmark;
this.loadImageBookmarks();
});
} else {
this.readerService.bookmarkEpub(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index).subscribe(bookmark => {
const newState = !hasBookmark;
icon.className = 'bookmark-overlay ' + (newState ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
hasBookmark = !hasBookmark;
this.loadImageBookmarks();
});
}
});
});
}
/**
* Updates the image properties to fit the current layout mode and screen size
*/