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
*/