diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 41b8420aa..67d6c3bb6 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -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(); } + /// + /// Creates a bookmark for an epub image + /// + /// + /// + [HttpPost("bookmark-epub")] + public async Task 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(); + } + /// /// Removes a bookmarked page for a Chapter /// diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index da18fc28e..c1b60e804 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -15,6 +15,10 @@ public sealed record BookmarkDto [Required] public int ChapterId { get; set; } /// + /// Only applicable for Epubs + /// + public int ImageOffset { get; set; } + /// /// This is only used when getting all bookmarks. /// public SeriesDto? Series { get; set; } diff --git a/API/Data/Migrations/20250708204811_BookAnnotations.Designer.cs b/API/Data/Migrations/20250710004325_BookAnnotations.Designer.cs similarity index 99% rename from API/Data/Migrations/20250708204811_BookAnnotations.Designer.cs rename to API/Data/Migrations/20250710004325_BookAnnotations.Designer.cs index f3c10a534..01e4c7b70 100644 --- a/API/Data/Migrations/20250708204811_BookAnnotations.Designer.cs +++ b/API/Data/Migrations/20250710004325_BookAnnotations.Designer.cs @@ -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 { /// @@ -241,6 +241,9 @@ namespace API.Data.Migrations b.Property("FileName") .HasColumnType("TEXT"); + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); diff --git a/API/Data/Migrations/20250708204811_BookAnnotations.cs b/API/Data/Migrations/20250710004325_BookAnnotations.cs similarity index 92% rename from API/Data/Migrations/20250708204811_BookAnnotations.cs rename to API/Data/Migrations/20250710004325_BookAnnotations.cs index 81fe51954..51afa4a70 100644 --- a/API/Data/Migrations/20250708204811_BookAnnotations.cs +++ b/API/Data/Migrations/20250710004325_BookAnnotations.cs @@ -23,6 +23,13 @@ namespace API.Data.Migrations type: "TEXT", nullable: true); + migrationBuilder.AddColumn( + 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"); } } } diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d5de68777..5c10c5c88 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -238,6 +238,9 @@ namespace API.Data.Migrations b.Property("FileName") .HasColumnType("TEXT"); + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index d17e8eaf0..4d55d101f 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -19,7 +19,11 @@ public class AppUserBookmark : IEntityDate /// Filename in the Bookmark Directory /// public string FileName { get; set; } = string.Empty; - + /// + /// Only applicable for Epubs - handles multiple images on one page + /// + /// 0-based index of the image position on page + public int ImageOffset { get; set; } // Relationships [JsonIgnore] diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index cfb026e0a..0ab755d56 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -61,6 +61,7 @@ public interface IBookService Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List ptocBookmarks, List annotations); Task> CreateKeyToPageMappingAsync(EpubBookRef book); Task?> GetWordCountsPerPage(string bookFilePath); + Task 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 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"); + } + /// /// 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 /// public async Task> 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(); - // - // if (navItems != null) - // { - // foreach (var navigationItem in navItems) - // { - // if (navigationItem.NestedItems.Count == 0) - // { - // CreateToCChapter(book, navigationItem, Array.Empty(), chaptersList, mappings); - // continue; - // } - // - // var nestedChapters = new List(); - // - // 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 mappings) + private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary 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; } diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 4cd77ddd9..2b0ac8058 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -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 /// /// Full path to the cached image that is going to be copied /// If the save to DB and copy was successful - public async Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark) + public async Task 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 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) diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 283d4b1ac..b9d228907 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -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); } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 3b3cb37d5..3343c090b 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -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()) { diff --git a/UI/Web/src/app/_models/readers/page-bookmark.ts b/UI/Web/src/app/_models/readers/page-bookmark.ts index 68feee118..88f30126a 100644 --- a/UI/Web/src/app/_models/readers/page-bookmark.ts +++ b/UI/Web/src/app/_models/readers/page-bookmark.ts @@ -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; } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 04f659ef4..1eb8c1797 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -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 | undefined) { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 526353125..c1d2dc21c 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -355,9 +355,7 @@ $pagination-opacity: 0; //$pagination-color: red; //$pagination-opacity: 0.7; -.kavita-scale-width::after { - content: ' '; -} + .right { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 0d2665e12..3b873c6b0 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -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([]); + /** * 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 = new EventEmitter(); - private readonly destroyRef = inject(DestroyRef); @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef; /** @@ -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 */