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.
This commit is contained in:
Joseph Milazzo 2025-06-29 16:45:44 -05:00
parent e5d949161e
commit 32ee60e1de
14 changed files with 257 additions and 76 deletions

View file

@ -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)
{

View file

@ -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();

View file

@ -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; }
}

View file

@ -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; }

View file

@ -1,8 +1,6 @@
// <auto-generated />
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<int>", 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 =>

View file

@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository
void Remove(AppUserTableOfContent toc);
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId);
Task<List<PersonalToCDto>> GetPersonalToCForPage(int userId, int chapterId, int page);
Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title);
}
@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository
.AsEnumerable();
}
public async Task<List<PersonalToCDto>> GetPersonalToCForPage(int userId, int chapterId, int page)
{
return await _context.AppUserTableOfContent
.Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == page)
.ProjectTo<PersonalToCDto>(_mapper.ConfigurationProvider)
.OrderBy(t => t.PageNumber)
.ToListAsync();
}
public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title)
{
return await _context.AppUserTableOfContent

View file

@ -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
/// </summary>
public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }

View file

@ -57,7 +57,7 @@ public interface IBookService
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
}
@ -321,6 +321,71 @@ public class BookService : IBookService
}
}
/// <summary>
/// For each bookmark on this page, inject a specialized icon
/// </summary>
/// <param name="doc"></param>
/// <param name="book"></param>
/// <param name="ptocBookmarks"></param>
private static void InjectPTOCBookmarks(HtmlDocument doc, EpubBookRef book, List<PersonalToCDto> 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($"<i class='fa-solid fa-bookmark ps-1 pe-1' role='button' id='ptoc-{bookmark.Id}' title='{bookmark.Title}'></i>"));
}
}
private static void InjectHighlights(HtmlDocument doc, EpubBookRef book, List<PersonalToCDto> 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($"<app-epub-highlight>{highlightedText}</app-epub-highlight>");
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($"<app-epub-highlight>{originalText}</app-epub-highlight>");
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
/// <param name="body">Body element from the epub</param>
/// <param name="mappings">Epub mappings</param>
/// <param name="page">Page number we are loading</param>
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
/// <returns></returns>
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page)
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page, List<PersonalToCDto> 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
/// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param>
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
/// <exception cref="KavitaException">All exceptions throw this</exception>
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl)
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> 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)
{

View file

@ -326,8 +326,8 @@ export class ReaderService {
return this.httpClient.get<Array<PersonalToC>>(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) {

View file

@ -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(() => {

View file

@ -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<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
@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>(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'))

View file

@ -0,0 +1,6 @@
<span
[class]="highlightClasses()"
[attr.data-highlight-color]="color()">
<i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>
<ng-content></ng-content>
</span>

View file

@ -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);
}

View file

@ -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<boolean>(false);
color = input<HighlightColor>('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());
}
}