From 32ee60e1de9af335947f46fd52835bd93eac0c35 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 29 Jun 2025 16:45:44 -0500 Subject: [PATCH] Added a poc marker for where a bookmark resides. Added a marker where a highlight might reside too. Can move forward with a proper implementation. --- API/Controllers/BookController.cs | 5 +- API/Controllers/ReaderController.cs | 2 + API/DTOs/Reader/CreatePersonalToCDto.cs | 1 + API/DTOs/Reader/PersonalToCDto.cs | 1 + .../Migrations/DataContextModelSnapshot.cs | 122 +++++++++--------- .../UserTableOfContentRepository.cs | 10 ++ API/Entities/AppUserTableOfContent.cs | 4 + API/Services/BookService.cs | 81 +++++++++++- UI/Web/src/app/_services/reader.service.ts | 4 +- .../book-line-overlay.component.ts | 12 +- .../book-reader/book-reader.component.ts | 23 +++- .../epub-highlight.component.html | 6 + .../epub-highlight.component.scss | 32 +++++ .../epub-highlight.component.ts | 30 +++++ 14 files changed, 257 insertions(+), 76 deletions(-) create mode 100644 UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.html create mode 100644 UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.scss create mode 100644 UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.ts diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index e1d7da9e8..ebd1b63b5 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -157,7 +157,10 @@ public class BookController : BaseApiController try { - return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); + var ptocBookmarks = + await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page); + + return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks)); } catch (KavitaException ex) { diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 38a5ad482..1ec92d125 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -879,6 +879,7 @@ public class ReaderController : BaseApiController return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark")); } + _unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent() { Title = dto.Title.Trim(), @@ -887,6 +888,7 @@ public class ReaderController : BaseApiController SeriesId = dto.SeriesId, LibraryId = dto.LibraryId, BookScrollId = dto.BookScrollId, + SelectedText = dto.SelectedText, AppUserId = userId }); await _unitOfWork.CommitAsync(); diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 95272ca58..545e17e47 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -10,4 +10,5 @@ public sealed record CreatePersonalToCDto public required int PageNumber { get; set; } public required string Title { get; set; } public string? BookScrollId { get; set; } + public string? SelectedText { get; set; } } diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs index c979d9d78..a5144120b 100644 --- a/API/DTOs/Reader/PersonalToCDto.cs +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -4,6 +4,7 @@ public sealed record PersonalToCDto { + public required int Id { get; init; } public required int ChapterId { get; set; } public required int PageNumber { get; set; } public required string Title { get; set; } diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 106a86b4a..63b848791 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1,8 +1,6 @@ // using System; -using System.Collections.Generic; using API.Data; -using API.Entities.MetadataMatching; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -194,7 +192,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserBookmark"); + b.ToTable("AppUserBookmark", (string)null); }); modelBuilder.Entity("API.Entities.AppUserChapterRating", b => @@ -229,7 +227,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserChapterRating"); + b.ToTable("AppUserChapterRating", (string)null); }); modelBuilder.Entity("API.Entities.AppUserCollection", b => @@ -301,7 +299,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserCollection"); + b.ToTable("AppUserCollection", (string)null); }); modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => @@ -341,7 +339,7 @@ namespace API.Data.Migrations b.HasIndex("Visible"); - b.ToTable("AppUserDashboardStream"); + b.ToTable("AppUserDashboardStream", (string)null); }); modelBuilder.Entity("API.Entities.AppUserExternalSource", b => @@ -366,7 +364,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserExternalSource"); + b.ToTable("AppUserExternalSource", (string)null); }); modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => @@ -387,7 +385,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserOnDeckRemoval"); + b.ToTable("AppUserOnDeckRemoval", (string)null); }); modelBuilder.Entity("API.Entities.AppUserPreferences", b => @@ -525,7 +523,7 @@ namespace API.Data.Migrations b.HasIndex("ThemeId"); - b.ToTable("AppUserPreferences"); + b.ToTable("AppUserPreferences", (string)null); }); modelBuilder.Entity("API.Entities.AppUserProgress", b => @@ -575,7 +573,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserProgresses"); + b.ToTable("AppUserProgresses", (string)null); }); modelBuilder.Entity("API.Entities.AppUserRating", b => @@ -608,7 +606,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserRating"); + b.ToTable("AppUserRating", (string)null); }); modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => @@ -725,7 +723,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserReadingProfiles"); + b.ToTable("AppUserReadingProfiles", (string)null); }); modelBuilder.Entity("API.Entities.AppUserRole", b => @@ -786,7 +784,7 @@ namespace API.Data.Migrations b.HasIndex("Visible"); - b.ToTable("AppUserSideNavStream"); + b.ToTable("AppUserSideNavStream", (string)null); }); modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => @@ -808,7 +806,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserSmartFilter"); + b.ToTable("AppUserSmartFilter", (string)null); }); modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => @@ -861,7 +859,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserTableOfContent"); + b.ToTable("AppUserTableOfContent", (string)null); }); modelBuilder.Entity("API.Entities.AppUserWantToRead", b => @@ -882,7 +880,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserWantToRead"); + b.ToTable("AppUserWantToRead", (string)null); }); modelBuilder.Entity("API.Entities.Chapter", b => @@ -1081,7 +1079,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("Chapter"); + b.ToTable("Chapter", (string)null); }); modelBuilder.Entity("API.Entities.CollectionTag", b => @@ -1116,7 +1114,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "Promoted") .IsUnique(); - b.ToTable("CollectionTag"); + b.ToTable("CollectionTag", (string)null); }); modelBuilder.Entity("API.Entities.Device", b => @@ -1162,7 +1160,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("Device"); + b.ToTable("Device", (string)null); }); modelBuilder.Entity("API.Entities.EmailHistory", b => @@ -1213,7 +1211,7 @@ namespace API.Data.Migrations b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); - b.ToTable("EmailHistory"); + b.ToTable("EmailHistory", (string)null); }); modelBuilder.Entity("API.Entities.FolderPath", b => @@ -1235,7 +1233,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("FolderPath"); + b.ToTable("FolderPath", (string)null); }); modelBuilder.Entity("API.Entities.Genre", b => @@ -1255,7 +1253,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Genre"); + b.ToTable("Genre", (string)null); }); modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => @@ -1275,7 +1273,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("ManualMigrationHistory"); + b.ToTable("ManualMigrationHistory", (string)null); }); modelBuilder.Entity("API.Entities.Library", b => @@ -1349,7 +1347,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Library"); + b.ToTable("Library", (string)null); }); modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => @@ -1368,7 +1366,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("LibraryExcludePattern"); + b.ToTable("LibraryExcludePattern", (string)null); }); modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => @@ -1387,7 +1385,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("LibraryFileTypeGroup"); + b.ToTable("LibraryFileTypeGroup", (string)null); }); modelBuilder.Entity("API.Entities.MangaFile", b => @@ -1442,7 +1440,7 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); - b.ToTable("MangaFile"); + b.ToTable("MangaFile", (string)null); }); modelBuilder.Entity("API.Entities.MediaError", b => @@ -1477,7 +1475,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("MediaError"); + b.ToTable("MediaError", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => @@ -1511,7 +1509,7 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); - b.ToTable("ExternalRating"); + b.ToTable("ExternalRating", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => @@ -1548,7 +1546,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ExternalRecommendation"); + b.ToTable("ExternalRecommendation", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => @@ -1600,7 +1598,7 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); - b.ToTable("ExternalReview"); + b.ToTable("ExternalReview", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => @@ -1635,7 +1633,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId") .IsUnique(); - b.ToTable("ExternalSeriesMetadata"); + b.ToTable("ExternalSeriesMetadata", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => @@ -1654,7 +1652,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("SeriesBlacklist"); + b.ToTable("SeriesBlacklist", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => @@ -1769,7 +1767,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "SeriesId") .IsUnique(); - b.ToTable("SeriesMetadata"); + b.ToTable("SeriesMetadata", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => @@ -1793,7 +1791,7 @@ namespace API.Data.Migrations b.HasIndex("TargetSeriesId"); - b.ToTable("SeriesRelation"); + b.ToTable("SeriesRelation", (string)null); }); modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => @@ -1824,7 +1822,7 @@ namespace API.Data.Migrations b.HasIndex("MetadataSettingsId"); - b.ToTable("MetadataFieldMapping"); + b.ToTable("MetadataFieldMapping", (string)null); }); modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => @@ -1902,7 +1900,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("MetadataSettings"); + b.ToTable("MetadataSettings", (string)null); }); modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => @@ -1926,7 +1924,7 @@ namespace API.Data.Migrations b.HasIndex("PersonId"); - b.ToTable("ChapterPeople"); + b.ToTable("ChapterPeople", (string)null); }); modelBuilder.Entity("API.Entities.Person.Person", b => @@ -1970,7 +1968,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Person"); + b.ToTable("Person", (string)null); }); modelBuilder.Entity("API.Entities.Person.PersonAlias", b => @@ -1992,7 +1990,7 @@ namespace API.Data.Migrations b.HasIndex("PersonId"); - b.ToTable("PersonAlias"); + b.ToTable("PersonAlias", (string)null); }); modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => @@ -2018,7 +2016,7 @@ namespace API.Data.Migrations b.HasIndex("PersonId"); - b.ToTable("SeriesMetadataPeople"); + b.ToTable("SeriesMetadataPeople", (string)null); }); modelBuilder.Entity("API.Entities.ReadingList", b => @@ -2087,7 +2085,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("ReadingList"); + b.ToTable("ReadingList", (string)null); }); modelBuilder.Entity("API.Entities.ReadingListItem", b => @@ -2121,7 +2119,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("ReadingListItem"); + b.ToTable("ReadingListItem", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => @@ -2166,7 +2164,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleError"); + b.ToTable("ScrobbleError", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => @@ -2243,7 +2241,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleEvent"); + b.ToTable("ScrobbleEvent", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => @@ -2276,7 +2274,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleHold"); + b.ToTable("ScrobbleHold", (string)null); }); modelBuilder.Entity("API.Entities.Series", b => @@ -2382,7 +2380,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("Series"); + b.ToTable("Series", (string)null); }); modelBuilder.Entity("API.Entities.ServerSetting", b => @@ -2399,7 +2397,7 @@ namespace API.Data.Migrations b.HasKey("Key"); - b.ToTable("ServerSetting"); + b.ToTable("ServerSetting", (string)null); }); modelBuilder.Entity("API.Entities.ServerStatistics", b => @@ -2437,7 +2435,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("ServerStatistics"); + b.ToTable("ServerStatistics", (string)null); }); modelBuilder.Entity("API.Entities.SiteTheme", b => @@ -2493,7 +2491,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("SiteTheme"); + b.ToTable("SiteTheme", (string)null); }); modelBuilder.Entity("API.Entities.Tag", b => @@ -2513,7 +2511,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Tag"); + b.ToTable("Tag", (string)null); }); modelBuilder.Entity("API.Entities.Volume", b => @@ -2583,7 +2581,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("Volume"); + b.ToTable("Volume", (string)null); }); modelBuilder.Entity("AppUserCollectionSeries", b => @@ -2598,7 +2596,7 @@ namespace API.Data.Migrations b.HasIndex("ItemsId"); - b.ToTable("AppUserCollectionSeries"); + b.ToTable("AppUserCollectionSeries", (string)null); }); modelBuilder.Entity("AppUserLibrary", b => @@ -2613,7 +2611,7 @@ namespace API.Data.Migrations b.HasIndex("LibrariesId"); - b.ToTable("AppUserLibrary"); + b.ToTable("AppUserLibrary", (string)null); }); modelBuilder.Entity("ChapterGenre", b => @@ -2628,7 +2626,7 @@ namespace API.Data.Migrations b.HasIndex("GenresId"); - b.ToTable("ChapterGenre"); + b.ToTable("ChapterGenre", (string)null); }); modelBuilder.Entity("ChapterTag", b => @@ -2643,7 +2641,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("ChapterTag"); + b.ToTable("ChapterTag", (string)null); }); modelBuilder.Entity("CollectionTagSeriesMetadata", b => @@ -2658,7 +2656,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("CollectionTagSeriesMetadata"); + b.ToTable("CollectionTagSeriesMetadata", (string)null); }); modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => @@ -2673,7 +2671,7 @@ namespace API.Data.Migrations b.HasIndex("ExternalSeriesMetadatasId"); - b.ToTable("ExternalRatingExternalSeriesMetadata"); + b.ToTable("ExternalRatingExternalSeriesMetadata", (string)null); }); modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => @@ -2688,7 +2686,7 @@ namespace API.Data.Migrations b.HasIndex("ExternalSeriesMetadatasId"); - b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + b.ToTable("ExternalRecommendationExternalSeriesMetadata", (string)null); }); modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => @@ -2703,7 +2701,7 @@ namespace API.Data.Migrations b.HasIndex("ExternalSeriesMetadatasId"); - b.ToTable("ExternalReviewExternalSeriesMetadata"); + b.ToTable("ExternalReviewExternalSeriesMetadata", (string)null); }); modelBuilder.Entity("GenreSeriesMetadata", b => @@ -2718,7 +2716,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("GenreSeriesMetadata"); + b.ToTable("GenreSeriesMetadata", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -2817,7 +2815,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("SeriesMetadataTag"); + b.ToTable("SeriesMetadataTag", (string)null); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => diff --git a/API/Data/Repositories/UserTableOfContentRepository.cs b/API/Data/Repositories/UserTableOfContentRepository.cs index b640ec9a0..34b3994de 100644 --- a/API/Data/Repositories/UserTableOfContentRepository.cs +++ b/API/Data/Repositories/UserTableOfContentRepository.cs @@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository void Remove(AppUserTableOfContent toc); Task IsUnique(int userId, int chapterId, int page, string title); IEnumerable GetPersonalToC(int userId, int chapterId); + Task> GetPersonalToCForPage(int userId, int chapterId, int page); Task Get(int userId, int chapterId, int pageNum, string title); } @@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository .AsEnumerable(); } + public async Task> GetPersonalToCForPage(int userId, int chapterId, int page) + { + return await _context.AppUserTableOfContent + .Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == page) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(t => t.PageNumber) + .ToListAsync(); + } + public async Task Get(int userId,int chapterId, int pageNum, string title) { return await _context.AppUserTableOfContent diff --git a/API/Entities/AppUserTableOfContent.cs b/API/Entities/AppUserTableOfContent.cs index bc0f604bc..954fdd617 100644 --- a/API/Entities/AppUserTableOfContent.cs +++ b/API/Entities/AppUserTableOfContent.cs @@ -31,6 +31,10 @@ public class AppUserTableOfContent : IEntityDate /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page /// public string? BookScrollId { get; set; } + /// + /// Text of the bookmark + /// + public string? SelectedText { get; set; } public DateTime Created { get; set; } public DateTime CreatedUtc { get; set; } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 99fdd1400..35a4d819a 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -57,7 +57,7 @@ public interface IBookService /// Where the files will be extracted to. If doesn't exist, will be created. void ExtractPdfImages(string fileFilePath, string targetDirectory); Task> GenerateTableOfContents(Chapter chapter); - Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); + Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List ptocBookmarks); Task> CreateKeyToPageMappingAsync(EpubBookRef book); } @@ -321,6 +321,71 @@ public class BookService : IBookService } } + /// + /// For each bookmark on this page, inject a specialized icon + /// + /// + /// + /// + private static void InjectPTOCBookmarks(HtmlDocument doc, EpubBookRef book, List ptocBookmarks) + { + if (ptocBookmarks.Count == 0) return; + + foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId))) + { + var unscopedSelector = bookmark.BookScrollId.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant(); + var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector); + elem?.PrependChild(HtmlNode.CreateNode($"")); + } + } + + + private static void InjectHighlights(HtmlDocument doc, EpubBookRef book, List ptocBookmarks) + { + if (ptocBookmarks.Count == 0) return; + + foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId))) + { + var unscopedSelector = bookmark.BookScrollId.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant(); + var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector); + if (elem == null) continue; + + // For this POC, assume we have 16 characters highlighted and those characters are: "For the past few" + // Get the original text content + var originalText = elem.InnerText; + + // For POC: highlight first 16 characters + const int highlightLength = 16; + + if (originalText.Length > highlightLength) + { + var highlightedText = originalText.Substring(0, highlightLength); + var remainingText = originalText.Substring(highlightLength); + + // Clear the existing content + elem.RemoveAllChildren(); + + // Create the highlight element with the first 16 characters + var highlightNode = HtmlNode.CreateNode($"{highlightedText}"); + elem.AppendChild(highlightNode); + + // Add the remaining text as a text node + var remainingTextNode = HtmlNode.CreateNode(remainingText); + elem.AppendChild(remainingTextNode); + } + else + { + // If text is shorter than highlight length, wrap it all + var highlightNode = HtmlNode.CreateNode($"{originalText}"); + elem.RemoveAllChildren(); + elem.AppendChild(highlightNode); + } + } + } + + + + private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) { var images = doc.DocumentNode.SelectNodes("//img") @@ -1016,8 +1081,9 @@ public class BookService : IBookService /// Body element from the epub /// Epub mappings /// Page number we are loading + /// Ptoc Bookmarks to tie against /// - private async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page) + private async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page, List ptocBookmarks) { await InlineStyles(doc, book, apiBase, body); @@ -1025,6 +1091,13 @@ public class BookService : IBookService ScopeImages(doc, book, apiBase); + // Inject PTOC Bookmark Icons + InjectPTOCBookmarks(doc, book, ptocBookmarks); + + + // MOCK: This will mimic a highlight + InjectHighlights(doc, book, ptocBookmarks); + return PrepareFinalHtml(doc, body); } @@ -1215,7 +1288,7 @@ public class BookService : IBookService /// The API base for Kavita, to rewrite urls to so we load though our endpoint /// Full epub HTML Page, scoped to Kavita's reader /// All exceptions throw this - public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) + public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List ptocBookmarks) { using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); @@ -1257,7 +1330,7 @@ public class BookService : IBookService body = doc.DocumentNode.SelectSingleNode("/html/body"); } - return await ScopePage(doc, book, apiBase, body, mappings, page); + return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks); } } catch (Exception ex) { diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 52aef2a4a..31d56e9ea 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -326,8 +326,8 @@ export class ReaderService { return this.httpClient.get>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId); } - createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null) { - return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId}); + createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null, selectedText: string) { + return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText}); } getElementFromXPath(path: string) { diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 000a7fad2..bca88aea6 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -1,11 +1,15 @@ import { - ChangeDetectionStrategy, ChangeDetectorRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, DestroyRef, - ElementRef, EventEmitter, HostListener, + ElementRef, + EventEmitter, + HostListener, inject, Input, - OnInit, Output, + OnInit, + Output, } from '@angular/core'; import {CommonModule} from '@angular/common'; import {fromEvent, merge, of} from "rxjs"; @@ -125,7 +129,7 @@ export class BookLineOverlayComponent implements OnInit { createPTOC() { this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, - this.bookmarkForm.get('name')?.value, this.xPath).pipe(catchError(err => { + this.bookmarkForm.get('name')?.value, this.xPath, this.selectedText).pipe(catchError(err => { this.focusOnBookmarkInput(); return of(); })).subscribe(() => { 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 ebfa82c7c..5febb773b 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 @@ -13,7 +13,8 @@ import { OnInit, Renderer2, RendererStyleFlags2, - ViewChild + ViewChild, + ViewContainerRef } from '@angular/core'; import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common'; import {ActivatedRoute, Router} from '@angular/router'; @@ -63,6 +64,7 @@ import { import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; import {ConfirmService} from "../../../shared/confirm.service"; +import {EpubHighlightComponent} from "../epub-highlight/epub-highlight.component"; enum TabID { @@ -351,6 +353,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; @ViewChild('reader', {static: false}) reader!: ElementRef; + @ViewChild('readingHtml', { read: ViewContainerRef }) readingContainer!: ViewContainerRef; /** * Disables the Left most button @@ -543,7 +546,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }) ) .subscribe(); - } handleScrollEvent() { @@ -1093,6 +1095,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.saveProgress(); this.isLoading = false; this.cdRef.markForCheck(); + + // Make the highlight components "real" + const highlightElems = this.document.querySelectorAll('app-epub-highlight'); + + for (let i = 0; i < highlightElems.length; i++) { + const highlight = highlightElems[i]; + const componentRef = this.readingContainer.createComponent(EpubHighlightComponent, + {projectableNodes: [[document.createTextNode(highlight.innerHTML)]]}); + if (highlight.parentNode != null) { + highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight); + } + //componentRef.instance.cdRef.markForCheck(); + } + + } private addEmptyPageIfRequired(): void { @@ -1316,7 +1333,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getFirstVisibleElementXPath() { let resumeElement: string | null = null; - if (this.bookContentElemRef === null) return null; + if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null; const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) .filter(element => !element.classList.contains('no-observe')) diff --git a/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.html b/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.html new file mode 100644 index 000000000..f8c6f7c0a --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.html @@ -0,0 +1,6 @@ + + + + diff --git a/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.scss b/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.scss new file mode 100644 index 000000000..076648327 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.scss @@ -0,0 +1,32 @@ +.epub-highlight { + position: relative; + transition: all 0.2s ease-in-out; +} + +.epub-highlight-blue { + background-color: rgba(59, 130, 246, 0.3); + border-radius: 3px; + padding: 1px 2px; +} + +.epub-highlight-green { + background-color: rgba(34, 197, 94, 0.3); + border-radius: 3px; + padding: 1px 2px; +} + +.epub-highlight-blue:hover { + background-color: rgba(59, 130, 246, 0.4); +} + +.epub-highlight-green:hover { + background-color: rgba(34, 197, 94, 0.4); +} + +.epub-highlight-blue { + border-bottom: 1px solid rgba(59, 130, 246, 0.5); +} + +.epub-highlight-green { + border-bottom: 1px solid rgba(34, 197, 94, 0.5); +} diff --git a/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.ts b/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.ts new file mode 100644 index 000000000..430a61ce0 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/epub-highlight/epub-highlight.component.ts @@ -0,0 +1,30 @@ +import {Component, computed, input, model} from '@angular/core'; + +export type HighlightColor = 'blue' | 'green'; + +@Component({ + selector: 'app-epub-highlight', + imports: [], + templateUrl: './epub-highlight.component.html', + styleUrl: './epub-highlight.component.scss' +}) +export class EpubHighlightComponent { + showHighlight = model(false); + color = input('blue'); + + highlightClasses = computed(() => { + const baseClass = 'epub-highlight'; + + if (!this.showHighlight()) { + return baseClass; + } + + const colorClass = `epub-highlight-${this.color()}`; + return `${baseClass} ${colorClass}`; + }); + + + toggleHighlight() { + this.showHighlight.set(!this.showHighlight()); + } +}