mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-30 23:00:06 -04:00
Added ability to bookmark images in the epub reader.
This commit is contained in:
parent
6340867ba0
commit
f59d1de559
@ -16,6 +16,7 @@ using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
@ -734,6 +735,7 @@ public class ReaderController : BaseApiController
|
||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
||||
|
||||
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
||||
|
||||
var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
|
||||
|
||||
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
|
||||
@ -743,6 +745,39 @@ public class ReaderController : BaseApiController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bookmark for an epub image
|
||||
/// </summary>
|
||||
/// <param name="bookmarkDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("bookmark-epub")]
|
||||
public async Task<ActionResult> BookmarkEpubPage(BookmarkDto bookmarkDto)
|
||||
{
|
||||
|
||||
// Don't let user save past total pages.
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user == null) return new UnauthorizedResult();
|
||||
|
||||
if (!await _accountService.HasBookmarkPermission(user))
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
|
||||
|
||||
|
||||
|
||||
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
|
||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
||||
|
||||
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
||||
|
||||
var cachedFilePath = _cacheService.GetCachedFile(chapter);
|
||||
var path = await _bookService.CopyImageToTempFromBook(chapter.Id, bookmarkDto, cachedFilePath);
|
||||
|
||||
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
|
||||
|
||||
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a bookmarked page for a Chapter
|
||||
/// </summary>
|
||||
|
@ -15,6 +15,10 @@ public sealed record BookmarkDto
|
||||
[Required]
|
||||
public int ChapterId { get; set; }
|
||||
/// <summary>
|
||||
/// Only applicable for Epubs
|
||||
/// </summary>
|
||||
public int ImageOffset { get; set; }
|
||||
/// <summary>
|
||||
/// This is only used when getting all bookmarks.
|
||||
/// </summary>
|
||||
public SeriesDto? Series { get; set; }
|
||||
|
@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250708204811_BookAnnotations")]
|
||||
[Migration("20250710004325_BookAnnotations")]
|
||||
partial class BookAnnotations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@ -241,6 +241,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("FileName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ImageOffset")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
@ -23,6 +23,13 @@ namespace API.Data.Migrations
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ImageOffset",
|
||||
table: "AppUserBookmark",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserAnnotation",
|
||||
columns: table => new
|
||||
@ -87,6 +94,10 @@ namespace API.Data.Migrations
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SelectedText",
|
||||
table: "AppUserTableOfContent");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ImageOffset",
|
||||
table: "AppUserBookmark");
|
||||
}
|
||||
}
|
||||
}
|
@ -238,6 +238,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("FileName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ImageOffset")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -19,7 +19,11 @@ public class AppUserBookmark : IEntityDate
|
||||
/// Filename in the Bookmark Directory
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Only applicable for Epubs - handles multiple images on one page
|
||||
/// </summary>
|
||||
/// <remarks>0-based index of the image position on page</remarks>
|
||||
public int ImageOffset { get; set; }
|
||||
|
||||
// Relationships
|
||||
[JsonIgnore]
|
||||
|
@ -61,6 +61,7 @@ public interface IBookService
|
||||
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
|
||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
|
||||
Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath);
|
||||
}
|
||||
|
||||
public partial class BookService : IBookService
|
||||
@ -735,6 +736,7 @@ public partial class BookService : IBookService
|
||||
|
||||
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
|
||||
{
|
||||
// TODO: Refactor this to use the Async version
|
||||
try
|
||||
{
|
||||
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||
@ -1001,6 +1003,109 @@ public partial class BookService : IBookService
|
||||
return ret;
|
||||
}
|
||||
|
||||
public async Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(cachedBookPath, LenientBookReaderOptions);
|
||||
|
||||
var counter = 0;
|
||||
var doc = new HtmlDocument { OptionFixNestedTags = true };
|
||||
|
||||
var bookPages = await book.GetReadingOrderAsync();
|
||||
foreach (var contentFileRef in bookPages)
|
||||
{
|
||||
if (bookmarkDto.Page != counter || contentFileRef.ContentType != EpubContentType.XHTML_1_1)
|
||||
{
|
||||
counter++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await contentFileRef.ReadContentAsync();
|
||||
doc.LoadHtml(content);
|
||||
|
||||
var images = doc.DocumentNode.SelectNodes("//img")
|
||||
?? doc.DocumentNode.SelectNodes("//image");
|
||||
|
||||
if (images == null || images.Count == 0)
|
||||
{
|
||||
throw new KavitaException("No images found on the specified page");
|
||||
}
|
||||
|
||||
if (bookmarkDto.ImageOffset >= images.Count)
|
||||
{
|
||||
throw new KavitaException($"Image index {bookmarkDto.ImageOffset} is out of range. Page has {images.Count} images");
|
||||
}
|
||||
|
||||
var targetImage = images[bookmarkDto.ImageOffset];
|
||||
|
||||
// Get the image source attribute
|
||||
string? srcAttributeName = null;
|
||||
if (targetImage.Attributes["src"] != null)
|
||||
{
|
||||
srcAttributeName = "src";
|
||||
}
|
||||
else if (targetImage.Attributes["xlink:href"] != null)
|
||||
{
|
||||
srcAttributeName = "xlink:href";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(srcAttributeName))
|
||||
{
|
||||
throw new KavitaException("Image element does not have a valid source attribute");
|
||||
}
|
||||
|
||||
var imageSource = targetImage.Attributes[srcAttributeName].Value;
|
||||
|
||||
// Clean and get the correct key for the image
|
||||
var imageKey = CleanContentKeys(GetKeyForImage(book, imageSource));
|
||||
|
||||
// Check if it's an external URL
|
||||
if (imageKey.StartsWith("http"))
|
||||
{
|
||||
throw new KavitaException("Cannot copy external images");
|
||||
}
|
||||
|
||||
// Get the image file from the epub
|
||||
|
||||
if (!book.Content.Images.TryGetLocalFileRefByKey(imageKey, out var imageFile))
|
||||
{
|
||||
throw new KavitaException($"Image file not found in epub: {imageKey}");
|
||||
}
|
||||
|
||||
// Read the image content
|
||||
var imageContent = await imageFile.ReadContentAsBytesAsync();
|
||||
|
||||
// Determine file extension from the image key or content type
|
||||
var extension = Path.GetExtension(imageKey);
|
||||
if (string.IsNullOrEmpty(extension))
|
||||
{
|
||||
// Fallback to determining extension from content type
|
||||
extension = imageFile.ContentType switch
|
||||
{
|
||||
EpubContentType.IMAGE_JPEG => ".jpg",
|
||||
EpubContentType.IMAGE_PNG => ".png",
|
||||
EpubContentType.IMAGE_GIF => ".gif",
|
||||
EpubContentType.IMAGE_SVG => ".svg",
|
||||
_ => ".png"
|
||||
};
|
||||
}
|
||||
|
||||
// Create temp directory for this chapter if it doesn't exist
|
||||
var tempChapterDir = Path.Combine(_directoryService.TempDirectory, chapterId.ToString());
|
||||
_directoryService.ExistOrCreate(tempChapterDir);
|
||||
|
||||
// Generate unique filename
|
||||
var uniqueFilename = $"{Guid.NewGuid()}{extension}";
|
||||
var tempFilePath = Path.Combine(tempChapterDir, uniqueFilename);
|
||||
|
||||
// Write the image to the temp file
|
||||
await File.WriteAllBytesAsync(tempFilePath, imageContent);
|
||||
|
||||
return tempFilePath;
|
||||
}
|
||||
|
||||
throw new KavitaException($"Page {bookmarkDto.Page} not found in epub");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
|
||||
/// then null is returned. This expects only an epub file
|
||||
@ -1153,6 +1258,7 @@ public partial class BookService : IBookService
|
||||
|
||||
RewriteAnchors(page, doc, mappings);
|
||||
|
||||
// TODO: Pass bookmarks here for state management
|
||||
ScopeImages(doc, book, apiBase);
|
||||
|
||||
InjectImages(doc, book, apiBase);
|
||||
@ -1160,8 +1266,10 @@ public partial class BookService : IBookService
|
||||
// Inject PTOC Bookmark Icons
|
||||
InjectPTOCBookmarks(doc, book, ptocBookmarks);
|
||||
|
||||
// Inject Annotations
|
||||
InjectAnnotations(doc, book, annotations);
|
||||
|
||||
|
||||
return PrepareFinalHtml(doc, body);
|
||||
}
|
||||
|
||||
@ -1226,88 +1334,6 @@ public partial class BookService : IBookService
|
||||
/// <returns></returns>
|
||||
public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter)
|
||||
{
|
||||
// using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
|
||||
// var mappings = await CreateKeyToPageMappingAsync(book);
|
||||
//
|
||||
// var navItems = await book.GetNavigationAsync();
|
||||
// var chaptersList = new List<BookChapterItem>();
|
||||
//
|
||||
// if (navItems != null)
|
||||
// {
|
||||
// foreach (var navigationItem in navItems)
|
||||
// {
|
||||
// if (navigationItem.NestedItems.Count == 0)
|
||||
// {
|
||||
// CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// var nestedChapters = new List<BookChapterItem>();
|
||||
//
|
||||
// foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
|
||||
// {
|
||||
// var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
|
||||
// if (mappings.TryGetValue(key, out var mapping))
|
||||
// {
|
||||
// nestedChapters.Add(new BookChapterItem
|
||||
// {
|
||||
// Title = nestedChapter.Title,
|
||||
// Page = mapping,
|
||||
// Part = nestedChapter.Link?.Anchor ?? string.Empty,
|
||||
// Children = []
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (chaptersList.Count != 0) return chaptersList;
|
||||
// // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
|
||||
// var tocPage = book.Content.Html.Local.Select(s => s.Key)
|
||||
// .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||
// k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
||||
// if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
||||
//
|
||||
//
|
||||
// // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
|
||||
// if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
|
||||
// var content = await file.ReadContentAsync();
|
||||
//
|
||||
// var doc = new HtmlDocument();
|
||||
// doc.LoadHtml(content);
|
||||
//
|
||||
// // TODO: We may want to check if there is a toc.ncs file to better handle nested toc
|
||||
// // We could do a fallback first with ol/lis
|
||||
//
|
||||
//
|
||||
//
|
||||
// var anchors = doc.DocumentNode.SelectNodes("//a");
|
||||
// if (anchors == null) return chaptersList;
|
||||
//
|
||||
// foreach (var anchor in anchors)
|
||||
// {
|
||||
// if (!anchor.Attributes.Contains("href")) continue;
|
||||
//
|
||||
// var key = CoalesceKey(book, mappings, anchor.Attributes["href"].Value.Split("#")[0]);
|
||||
//
|
||||
// if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue;
|
||||
// var part = string.Empty;
|
||||
// if (anchor.Attributes["href"].Value.Contains('#'))
|
||||
// {
|
||||
// part = anchor.Attributes["href"].Value.Split("#")[1];
|
||||
// }
|
||||
// chaptersList.Add(new BookChapterItem
|
||||
// {
|
||||
// Title = anchor.InnerText,
|
||||
// Page = mappings[key],
|
||||
// Part = part,
|
||||
// Children = []
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// return chaptersList;
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
|
||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||
|
||||
@ -1318,7 +1344,7 @@ public partial class BookService : IBookService
|
||||
{
|
||||
foreach (var navigationItem in navItems)
|
||||
{
|
||||
var tocItem = CreateToCChapterRecursively(book, navigationItem, mappings);
|
||||
var tocItem = CreateToCChapter(book, navigationItem, mappings);
|
||||
if (tocItem != null)
|
||||
{
|
||||
chaptersList.Add(tocItem);
|
||||
@ -1368,7 +1394,7 @@ public partial class BookService : IBookService
|
||||
return chaptersList;
|
||||
}
|
||||
|
||||
private BookChapterItem? CreateToCChapterRecursively(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
|
||||
private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
|
||||
{
|
||||
// Get the page mapping for the current navigation item
|
||||
var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
|
||||
@ -1384,7 +1410,7 @@ public partial class BookService : IBookService
|
||||
{
|
||||
foreach (var nestedItem in navigationItem.NestedItems)
|
||||
{
|
||||
var childItem = CreateToCChapterRecursively(book, nestedItem, mappings);
|
||||
var childItem = CreateToCChapter(book, nestedItem, mappings);
|
||||
if (childItem != null)
|
||||
{
|
||||
children.Add(childItem);
|
||||
@ -1407,8 +1433,9 @@ public partial class BookService : IBookService
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int CountParentDirectory(string path)
|
||||
private static int CountParentDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return 0;
|
||||
return ParentDirectoryRegex().Matches(path).Count;
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
@ -113,12 +114,17 @@ public class BookmarkService : IBookmarkService
|
||||
/// <param name="bookmarkDto"></param>
|
||||
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</param>
|
||||
/// <returns>If the save to DB and copy was successful</returns>
|
||||
public async Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
|
||||
public async Task<bool> BookmarkPage(AppUser? userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
|
||||
{
|
||||
if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false;
|
||||
if (userWithBookmarks?.Bookmarks == null)
|
||||
{
|
||||
throw new KavitaException("Bookmarks cannot be null!");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId);
|
||||
var userBookmark = userWithBookmarks.Bookmarks
|
||||
.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId && b.ImageOffset == bookmarkDto.ImageOffset);
|
||||
if (userBookmark != null)
|
||||
{
|
||||
_logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page);
|
||||
@ -137,6 +143,7 @@ public class BookmarkService : IBookmarkService
|
||||
SeriesId = bookmarkDto.SeriesId,
|
||||
ChapterId = bookmarkDto.ChapterId,
|
||||
FileName = Path.Join(targetFolderStem, fileInfo.Name),
|
||||
ImageOffset = bookmarkDto.ImageOffset,
|
||||
AppUserId = userWithBookmarks.Id
|
||||
};
|
||||
|
||||
@ -170,7 +177,7 @@ public class BookmarkService : IBookmarkService
|
||||
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
|
||||
{
|
||||
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
|
||||
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page);
|
||||
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page && x.ImageOffset == bookmarkDto.ImageOffset);
|
||||
try
|
||||
{
|
||||
if (bookmarkToDelete != null)
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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())
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -102,14 +102,19 @@ export class ReaderService {
|
||||
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
||||
}
|
||||
|
||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
|
||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, xPath: string | null = null) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page, xPath});
|
||||
}
|
||||
|
||||
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
|
||||
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page, imageNumber});
|
||||
}
|
||||
|
||||
bookmarkEpub(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark-epub', {seriesId, volumeId, chapterId, page, imageNumber});
|
||||
}
|
||||
|
||||
|
||||
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
|
||||
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
|
||||
}
|
||||
|
@ -355,9 +355,7 @@ $pagination-opacity: 0;
|
||||
//$pagination-color: red;
|
||||
//$pagination-opacity: 0.7;
|
||||
|
||||
.kavita-scale-width::after {
|
||||
content: ' ';
|
||||
}
|
||||
|
||||
|
||||
|
||||
.right {
|
||||
|
@ -62,6 +62,7 @@ import {ColumnLayoutClassPipe} from "../../_pipes/column-layout-class.pipe";
|
||||
import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe";
|
||||
import {ChapterService} from "../../../_services/chapter.service";
|
||||
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
|
||||
import {PageBookmark} from "../../../_models/readers/page-bookmark";
|
||||
|
||||
|
||||
interface HistoryPoint {
|
||||
@ -128,6 +129,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly epubMenuService = inject(EpubReaderMenuService);
|
||||
protected readonly readerSettingsService = inject(EpubReaderSettingsService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
||||
protected readonly WritingStyle = WritingStyle;
|
||||
@ -271,6 +273,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
imageBookmarks = model<PageBookmark[]>([]);
|
||||
|
||||
/**
|
||||
* Anchors that map to the page number. When you click on one of these, we will load a given page up for the user.
|
||||
*/
|
||||
@ -309,9 +313,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
*/
|
||||
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
|
||||
|
||||
//writingStyle: WritingStyle = WritingStyle.Horizontal;
|
||||
|
||||
|
||||
/**
|
||||
* When the user is highlighting something, then we remove pagination
|
||||
*/
|
||||
@ -324,7 +325,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
*/
|
||||
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
|
||||
/**
|
||||
@ -414,7 +414,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
return this.pageNum() === 0 && (currentVirtualPage === 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
get PageWidthForPagination() {
|
||||
if (this.layoutMode() === BookPageLayoutMode.Default && this.writingStyle() === WritingStyle.Vertical && this.horizontalScrollbarNeeded) {
|
||||
@ -646,6 +646,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.nextChapterPrefetched = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.loadImageBookmarks();
|
||||
|
||||
|
||||
this.bookService.getBookInfo(this.chapterId, true).subscribe(async (info) => {
|
||||
if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) {
|
||||
@ -796,6 +798,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return 0;
|
||||
}
|
||||
|
||||
loadImageBookmarks() {
|
||||
this.readerService.getBookmarks(this.chapterId).subscribe(res => {
|
||||
this.imageBookmarks.set(res);
|
||||
});
|
||||
}
|
||||
|
||||
loadNextChapter() {
|
||||
if (this.nextPageDisabled) { return; }
|
||||
this.isLoading = true;
|
||||
@ -969,11 +977,82 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
.then(() => {
|
||||
this.setupPage(part, scrollTop);
|
||||
this.updateImageSizes();
|
||||
this.injectImageBookmarkIndicators();
|
||||
});
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Injects the new DOM needed to provide the bookmark functionality.
|
||||
* We can't use a wrapper due to potential for styling issues.
|
||||
*/
|
||||
injectImageBookmarkIndicators() {
|
||||
const imgs = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('img') ?? []);
|
||||
|
||||
|
||||
const bookmarksForPage = (this.imageBookmarks() ?? []).filter(b => b.page === this.pageNum());
|
||||
|
||||
imgs.forEach((img, index) => {
|
||||
if (img.nextElementSibling?.classList.contains('bookmark-overlay')) return;
|
||||
|
||||
const matchingBookmarks = bookmarksForPage.filter(b => b.imageOffset == index);
|
||||
|
||||
let hasBookmark = false;
|
||||
if (matchingBookmarks.length > 0) {
|
||||
hasBookmark = true;
|
||||
}
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'bookmark-overlay ' + (hasBookmark ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
|
||||
|
||||
//icon.attributes.title = hasBookmark ? 'Unbookmark' : 'Bookmark';
|
||||
icon.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
pointer-events: auto;
|
||||
`;
|
||||
|
||||
// Make parent relative if needed
|
||||
const parent = img.parentElement;
|
||||
if (parent == null) return;
|
||||
|
||||
if (getComputedStyle(parent).position === 'static') {
|
||||
parent.style.position = 'relative';
|
||||
}
|
||||
|
||||
parent.appendChild(icon);
|
||||
|
||||
icon.addEventListener('click', () => {
|
||||
if (hasBookmark) {
|
||||
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index).subscribe(bookmark => {
|
||||
const newState = !hasBookmark;
|
||||
icon.className = 'bookmark-overlay ' + (newState ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
|
||||
hasBookmark = !hasBookmark;
|
||||
this.loadImageBookmarks();
|
||||
});
|
||||
} else {
|
||||
this.readerService.bookmarkEpub(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index).subscribe(bookmark => {
|
||||
const newState = !hasBookmark;
|
||||
icon.className = 'bookmark-overlay ' + (newState ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
|
||||
hasBookmark = !hasBookmark;
|
||||
this.loadImageBookmarks();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the image properties to fit the current layout mode and screen size
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user