Compare commits
No commits in common. "feature/epub-highlight-part1" and "develop" have entirely different histories.
feature/ep
...
develop
68 changed files with 954 additions and 7343 deletions
|
|
@ -10,8 +10,4 @@ namespace API.Controllers;
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class BaseApiController : ControllerBase
|
public class BaseApiController : ControllerBase
|
||||||
{
|
{
|
||||||
public BaseApiController()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
@ -41,13 +40,11 @@ public class BookController : BaseApiController
|
||||||
/// <param name="chapterId"></param>
|
/// <param name="chapterId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("{chapterId}/book-info")]
|
[HttpGet("{chapterId}/book-info")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])]
|
|
||||||
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
|
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
|
||||||
{
|
{
|
||||||
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
||||||
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||||
var bookTitle = string.Empty;
|
var bookTitle = string.Empty;
|
||||||
|
|
||||||
switch (dto.SeriesFormat)
|
switch (dto.SeriesFormat)
|
||||||
{
|
{
|
||||||
case MangaFormat.Epub:
|
case MangaFormat.Epub:
|
||||||
|
|
@ -55,7 +52,6 @@ public class BookController : BaseApiController
|
||||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
|
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
|
||||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
|
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
|
||||||
bookTitle = book.Title;
|
bookTitle = book.Title;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MangaFormat.Pdf:
|
case MangaFormat.Pdf:
|
||||||
|
|
@ -76,9 +72,9 @@ public class BookController : BaseApiController
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = new BookInfoDto()
|
return Ok(new BookInfoDto()
|
||||||
{
|
{
|
||||||
ChapterNumber = dto.ChapterNumber,
|
ChapterNumber = dto.ChapterNumber,
|
||||||
VolumeNumber = dto.VolumeNumber,
|
VolumeNumber = dto.VolumeNumber,
|
||||||
VolumeId = dto.VolumeId,
|
VolumeId = dto.VolumeId,
|
||||||
BookTitle = bookTitle,
|
BookTitle = bookTitle,
|
||||||
|
|
@ -88,13 +84,7 @@ public class BookController : BaseApiController
|
||||||
LibraryId = dto.LibraryId,
|
LibraryId = dto.LibraryId,
|
||||||
IsSpecial = dto.IsSpecial,
|
IsSpecial = dto.IsSpecial,
|
||||||
Pages = dto.Pages,
|
Pages = dto.Pages,
|
||||||
};
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return Ok(info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -167,11 +157,7 @@ public class BookController : BaseApiController
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ptocBookmarks =
|
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl));
|
||||||
await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page);
|
|
||||||
var annotations = await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapter.Id);
|
|
||||||
|
|
||||||
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations));
|
|
||||||
}
|
}
|
||||||
catch (KavitaException ex)
|
catch (KavitaException ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks.Metadata;
|
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
|
|
@ -42,7 +41,6 @@ public class ReaderController : BaseApiController
|
||||||
private readonly IEventHub _eventHub;
|
private readonly IEventHub _eventHub;
|
||||||
private readonly IScrobblingService _scrobblingService;
|
private readonly IScrobblingService _scrobblingService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
private readonly IBookService _bookService;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ReaderController(ICacheService cacheService,
|
public ReaderController(ICacheService cacheService,
|
||||||
|
|
@ -50,8 +48,7 @@ public class ReaderController : BaseApiController
|
||||||
IReaderService readerService, IBookmarkService bookmarkService,
|
IReaderService readerService, IBookmarkService bookmarkService,
|
||||||
IAccountService accountService, IEventHub eventHub,
|
IAccountService accountService, IEventHub eventHub,
|
||||||
IScrobblingService scrobblingService,
|
IScrobblingService scrobblingService,
|
||||||
ILocalizationService localizationService,
|
ILocalizationService localizationService)
|
||||||
IBookService bookService)
|
|
||||||
{
|
{
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
|
@ -62,7 +59,6 @@ public class ReaderController : BaseApiController
|
||||||
_eventHub = eventHub;
|
_eventHub = eventHub;
|
||||||
_scrobblingService = scrobblingService;
|
_scrobblingService = scrobblingService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_bookService = bookService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -222,10 +218,11 @@ public class ReaderController : BaseApiController
|
||||||
/// <remarks>This is generally the first call when attempting to read to allow pre-generation of assets needed for reading</remarks>
|
/// <remarks>This is generally the first call when attempting to read to allow pre-generation of assets needed for reading</remarks>
|
||||||
/// <param name="chapterId"></param>
|
/// <param name="chapterId"></param>
|
||||||
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
|
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
|
||||||
/// <param name="includeDimensions">Include file dimensions. Only useful for image-based reading</param>
|
/// <param name="includeDimensions">Include file dimensions. Only useful for image based reading</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("chapter-info")]
|
[HttpGet("chapter-info")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])]
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"
|
||||||
|
])]
|
||||||
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
|
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
|
||||||
{
|
{
|
||||||
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
|
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
|
||||||
|
|
@ -829,58 +826,6 @@ public class ReaderController : BaseApiController
|
||||||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For the current user, returns an estimate on how long it would take to finish reading the chapter.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.</remarks>
|
|
||||||
/// <param name="seriesId"></param>
|
|
||||||
/// <param name="chapterId"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[HttpGet("time-left-for-chapter")]
|
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "chapterId"])]
|
|
||||||
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletionForChapter(int seriesId, int chapterId)
|
|
||||||
{
|
|
||||||
var userId = User.GetUserId();
|
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
|
||||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
|
||||||
if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
|
||||||
|
|
||||||
// Patch in the reading progress
|
|
||||||
await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter);
|
|
||||||
|
|
||||||
if (series.Format == MangaFormat.Epub)
|
|
||||||
{
|
|
||||||
// Get the word counts for all the pages
|
|
||||||
var pageCounts = await _bookService.GetWordCountsPerPage(chapter.Files.First().FilePath); // TODO: Cache
|
|
||||||
if (pageCounts == null) return _readerService.GetTimeEstimate(series.WordCount, 0, true);
|
|
||||||
|
|
||||||
// Sum character counts only for pages that have been read
|
|
||||||
var totalCharactersRead = pageCounts
|
|
||||||
.Where(kvp => kvp.Key <= chapter.PagesRead)
|
|
||||||
.Sum(kvp => kvp.Value);
|
|
||||||
|
|
||||||
var progressCount = WordCountAnalyzerService.GetWordCount(totalCharactersRead);
|
|
||||||
var wordsLeft = series.WordCount - progressCount;
|
|
||||||
return _readerService.GetTimeEstimate(wordsLeft, 0, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var pagesLeft = chapter.Pages - chapter.PagesRead;
|
|
||||||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the annotations for the given chapter
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="chapterId"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[HttpGet("annotations")]
|
|
||||||
public async Task<ActionResult<IEnumerable<AnnotationDto>>> GetAnnotations(int chapterId)
|
|
||||||
{
|
|
||||||
|
|
||||||
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the user's personal table of contents for the given chapter
|
/// Returns the user's personal table of contents for the given chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -934,12 +879,6 @@ public class ReaderController : BaseApiController
|
||||||
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
|
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the chapter this PTOC is associated with to get the chapter title (if there is one)
|
|
||||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
|
|
||||||
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
|
|
||||||
var toc = await _bookService.GenerateTableOfContents(chapter);
|
|
||||||
var chapterTitle = BookService.GetChapterTitleFromToC(toc, dto.PageNumber);
|
|
||||||
|
|
||||||
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
|
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
|
||||||
{
|
{
|
||||||
Title = dto.Title.Trim(),
|
Title = dto.Title.Trim(),
|
||||||
|
|
@ -948,8 +887,6 @@ public class ReaderController : BaseApiController
|
||||||
SeriesId = dto.SeriesId,
|
SeriesId = dto.SeriesId,
|
||||||
LibraryId = dto.LibraryId,
|
LibraryId = dto.LibraryId,
|
||||||
BookScrollId = dto.BookScrollId,
|
BookScrollId = dto.BookScrollId,
|
||||||
SelectedText = dto.SelectedText,
|
|
||||||
ChapterTitle = chapterTitle,
|
|
||||||
AppUserId = userId
|
AppUserId = userId
|
||||||
});
|
});
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
using System;
|
|
||||||
using API.Entities;
|
|
||||||
using API.Entities.Enums;
|
|
||||||
|
|
||||||
namespace API.DTOs.Reader;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents an annotation on a book
|
|
||||||
/// </summary>
|
|
||||||
public sealed record AnnotationDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Starting point of the Highlight
|
|
||||||
/// </summary>
|
|
||||||
public required string XPath { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
|
|
||||||
/// </summary>
|
|
||||||
public string EndingXPath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The text selected.
|
|
||||||
/// </summary>
|
|
||||||
public string SelectedText { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Rich text Comment
|
|
||||||
/// </summary>
|
|
||||||
public string? Comment { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// The number of characters selected
|
|
||||||
/// </summary>
|
|
||||||
public int HighlightCount { get; set; }
|
|
||||||
public bool ContainsSpoiler { get; set; }
|
|
||||||
public int PageNumber { get; set; }
|
|
||||||
|
|
||||||
public HightlightColor HighlightColor { get; set; }
|
|
||||||
|
|
||||||
public required int ChapterId { get; set; }
|
|
||||||
|
|
||||||
public required int OwnerUserId { get; set; }
|
|
||||||
public string OwnerUsername { get; set; }
|
|
||||||
|
|
||||||
public DateTime CreatedUtc { get; set; }
|
|
||||||
public DateTime LastModifiedUtc { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
using API.Entities.Enums;
|
|
||||||
|
|
||||||
namespace API.DTOs.Reader;
|
|
||||||
|
|
||||||
public sealed record CreateAnnotationRequest
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Starting point of the Highlight
|
|
||||||
/// </summary>
|
|
||||||
public required string XPath { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
|
|
||||||
/// </summary>
|
|
||||||
public string EndingXPath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The text selected.
|
|
||||||
/// </summary>
|
|
||||||
public string SelectedText { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Rich text Comment
|
|
||||||
/// </summary>
|
|
||||||
public string? Comment { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// The number of characters selected
|
|
||||||
/// </summary>
|
|
||||||
public int HighlightCount { get; set; }
|
|
||||||
public bool ContainsSpoiler { get; set; }
|
|
||||||
public int PageNumber { get; set; }
|
|
||||||
|
|
||||||
public HightlightColor HighlightColor { get; set; }
|
|
||||||
|
|
||||||
public required int ChapterId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -10,5 +10,4 @@ public sealed record CreatePersonalToCDto
|
||||||
public required int PageNumber { get; set; }
|
public required int PageNumber { get; set; }
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public string? BookScrollId { get; set; }
|
public string? BookScrollId { get; set; }
|
||||||
public string? SelectedText { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,8 @@
|
||||||
|
|
||||||
public sealed record PersonalToCDto
|
public sealed record PersonalToCDto
|
||||||
{
|
{
|
||||||
public required int Id { get; init; }
|
|
||||||
public required int ChapterId { get; set; }
|
public required int ChapterId { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The page to bookmark
|
|
||||||
/// </summary>
|
|
||||||
public required int PageNumber { get; set; }
|
public required int PageNumber { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
|
|
||||||
/// </summary>
|
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// 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; }
|
public string? BookScrollId { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Text of the bookmark
|
|
||||||
/// </summary>
|
|
||||||
public string? SelectedText { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Title of the Chapter this PToC was created in
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>Taken from the ToC</remarks>
|
|
||||||
public string? ChapterTitle { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||||
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
||||||
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
||||||
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
||||||
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,92 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace API.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class BookAnnotations : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "ChapterTitle",
|
|
||||||
table: "AppUserTableOfContent",
|
|
||||||
type: "TEXT",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "SelectedText",
|
|
||||||
table: "AppUserTableOfContent",
|
|
||||||
type: "TEXT",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AppUserAnnotation",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
XPath = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
EndingXPath = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
SelectedText = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
Comment = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
HighlightCount = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
PageNumber = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
HighlightColor = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
ContainsSpoiler = table.Column<bool>(type: "INTEGER", nullable: false),
|
|
||||||
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
|
||||||
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
|
||||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
|
|
||||||
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AppUserAnnotation", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AppUserAnnotation_AspNetUsers_AppUserId",
|
|
||||||
column: x => x.AppUserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AppUserAnnotation_Chapter_ChapterId",
|
|
||||||
column: x => x.ChapterId,
|
|
||||||
principalTable: "Chapter",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AppUserAnnotation_AppUserId",
|
|
||||||
table: "AppUserAnnotation",
|
|
||||||
column: "AppUserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AppUserAnnotation_ChapterId",
|
|
||||||
table: "AppUserAnnotation",
|
|
||||||
column: "ChapterId");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AppUserAnnotation");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ChapterTitle",
|
|
||||||
table: "AppUserTableOfContent");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "SelectedText",
|
|
||||||
table: "AppUserTableOfContent");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -154,69 +154,6 @@ namespace API.Data.Migrations
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<int>("AppUserId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<int>("ChapterId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("Comment")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<bool>("ContainsSpoiler")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<DateTime>("Created")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedUtc")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("EndingXPath")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("HighlightColor")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<int>("HighlightCount")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastModified")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastModifiedUtc")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("PageNumber")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("SelectedText")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("SeriesId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<int>("VolumeId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("XPath")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AppUserId");
|
|
||||||
|
|
||||||
b.HasIndex("ChapterId");
|
|
||||||
|
|
||||||
b.ToTable("AppUserAnnotation");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -889,9 +826,6 @@ namespace API.Data.Migrations
|
||||||
b.Property<int>("ChapterId")
|
b.Property<int>("ChapterId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("ChapterTitle")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateTime>("Created")
|
b.Property<DateTime>("Created")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|
@ -910,9 +844,6 @@ namespace API.Data.Migrations
|
||||||
b.Property<int>("PageNumber")
|
b.Property<int>("PageNumber")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("SelectedText")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("SeriesId")
|
b.Property<int>("SeriesId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|
@ -2892,25 +2823,6 @@ namespace API.Data.Migrations
|
||||||
b.ToTable("SeriesMetadataTag");
|
b.ToTable("SeriesMetadataTag");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
|
||||||
.WithMany("Annotations")
|
|
||||||
.HasForeignKey("AppUserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("API.Entities.Chapter", "Chapter")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ChapterId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("AppUser");
|
|
||||||
|
|
||||||
b.Navigation("Chapter");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
|
|
@ -3697,8 +3609,6 @@ namespace API.Data.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUser", b =>
|
modelBuilder.Entity("API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Annotations");
|
|
||||||
|
|
||||||
b.Navigation("Bookmarks");
|
b.Navigation("Bookmarks");
|
||||||
|
|
||||||
b.Navigation("ChapterRatings");
|
b.Navigation("ChapterRatings");
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ public interface IUserRepository
|
||||||
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||||
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
||||||
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
||||||
Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserRepository : IUserRepository
|
public class UserRepository : IUserRepository
|
||||||
|
|
@ -551,28 +550,13 @@ public class UserRepository : IUserRepository
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="deviceEmail"></param>
|
/// <param name="deviceEmail"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<AppUser?> GetUserByDeviceEmail(string deviceEmail)
|
public async Task<AppUser> GetUserByDeviceEmail(string deviceEmail)
|
||||||
{
|
{
|
||||||
return await _context.AppUser
|
return await _context.AppUser
|
||||||
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
|
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a list of annotations ordered by page number. If the user has
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId"></param>
|
|
||||||
/// <param name="chapterId"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId)
|
|
||||||
{
|
|
||||||
// TODO: Check settings if I should include other user's annotations
|
|
||||||
return await _context.AppUserAnnotation
|
|
||||||
.Where(a => a.AppUserId == userId && a.ChapterId == chapterId)
|
|
||||||
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ public interface IUserTableOfContentRepository
|
||||||
void Remove(AppUserTableOfContent toc);
|
void Remove(AppUserTableOfContent toc);
|
||||||
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
|
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
|
||||||
IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId);
|
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);
|
Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,15 +55,6 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository
|
||||||
.AsEnumerable();
|
.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)
|
public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title)
|
||||||
{
|
{
|
||||||
return await _context.AppUserTableOfContent
|
return await _context.AppUserTableOfContent
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||||
/// A list of Table of Contents for a given Chapter
|
/// A list of Table of Contents for a given Chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
|
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
|
||||||
public ICollection<AppUserAnnotation> Annotations { get; set; } = null!;
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An API Key to interact with external services, like OPDS
|
/// An API Key to interact with external services, like OPDS
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
using System;
|
|
||||||
using API.Entities.Enums;
|
|
||||||
using API.Entities.Interfaces;
|
|
||||||
|
|
||||||
namespace API.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents an annotation in the Epub reader
|
|
||||||
/// </summary>
|
|
||||||
public class AppUserAnnotation : IEntityDate
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Starting point of the Highlight
|
|
||||||
/// </summary>
|
|
||||||
public required string XPath { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
|
|
||||||
/// </summary>
|
|
||||||
public string EndingXPath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The text selected.
|
|
||||||
/// </summary>
|
|
||||||
public string SelectedText { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Rich text Comment
|
|
||||||
/// </summary>
|
|
||||||
public string? Comment { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// The number of characters selected
|
|
||||||
/// </summary>
|
|
||||||
public int HighlightCount { get; set; }
|
|
||||||
public int PageNumber { get; set; }
|
|
||||||
|
|
||||||
public HightlightColor HighlightColor { get; set; }
|
|
||||||
public bool ContainsSpoiler { get; set; }
|
|
||||||
|
|
||||||
// TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?)
|
|
||||||
|
|
||||||
public required int SeriesId { get; set; }
|
|
||||||
public required int VolumeId { get; set; }
|
|
||||||
public required int ChapterId { get; set; }
|
|
||||||
public Chapter Chapter { get; set; }
|
|
||||||
|
|
||||||
public required int AppUserId { get; set; }
|
|
||||||
public AppUser AppUser { get; set; }
|
|
||||||
|
|
||||||
public DateTime Created { get; set; }
|
|
||||||
public DateTime CreatedUtc { get; set; }
|
|
||||||
public DateTime LastModified { get; set; }
|
|
||||||
public DateTime LastModifiedUtc { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -18,19 +18,6 @@ public class AppUserTableOfContent : IEntityDate
|
||||||
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
|
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// 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; }
|
|
||||||
/// <summary>
|
|
||||||
/// Title of the Chapter this PToC was created in
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>Taken from the ToC</remarks>
|
|
||||||
public string? ChapterTitle { get; set; }
|
|
||||||
|
|
||||||
public required int SeriesId { get; set; }
|
public required int SeriesId { get; set; }
|
||||||
public virtual Series Series { get; set; }
|
public virtual Series Series { get; set; }
|
||||||
|
|
@ -40,7 +27,10 @@ public class AppUserTableOfContent : IEntityDate
|
||||||
|
|
||||||
public int VolumeId { get; set; }
|
public int VolumeId { get; set; }
|
||||||
public int LibraryId { get; set; }
|
public int LibraryId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 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; }
|
||||||
|
|
||||||
public DateTime Created { get; set; }
|
public DateTime Created { get; set; }
|
||||||
public DateTime CreatedUtc { get; set; }
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
namespace API.Entities.Enums;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Color of the highlight
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>Color may not match exactly due to theming</remarks>
|
|
||||||
public enum HightlightColor
|
|
||||||
{
|
|
||||||
Blue = 1,
|
|
||||||
Green = 2,
|
|
||||||
}
|
|
||||||
|
|
@ -386,9 +386,7 @@ public class AutoMapperProfiles : Profile
|
||||||
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
|
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
|
||||||
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
||||||
|
|
||||||
CreateMap<AppUserAnnotation, AnnotationDto>()
|
|
||||||
.ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName))
|
|
||||||
.ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services.Tasks.Metadata;
|
|
||||||
using Docnet.Core;
|
using Docnet.Core;
|
||||||
using Docnet.Core.Converters;
|
using Docnet.Core.Converters;
|
||||||
using Docnet.Core.Models;
|
using Docnet.Core.Models;
|
||||||
|
|
@ -58,12 +57,11 @@ public interface IBookService
|
||||||
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
||||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||||
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
|
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
|
||||||
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
|
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
|
||||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||||
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class BookService : IBookService
|
public class BookService : IBookService
|
||||||
{
|
{
|
||||||
private readonly ILogger<BookService> _logger;
|
private readonly ILogger<BookService> _logger;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
|
|
@ -323,71 +321,6 @@ public partial 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 InjectAnnotations(HtmlDocument doc, EpubBookRef book, List<AnnotationDto> annotations)
|
|
||||||
{
|
|
||||||
if (annotations.Count == 0) return;
|
|
||||||
|
|
||||||
foreach (var annotation in annotations.Where(b => !string.IsNullOrEmpty(b.XPath)))
|
|
||||||
{
|
|
||||||
var unscopedSelector = annotation.XPath.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
|
|
||||||
var highlightLength = annotation.HighlightCount;
|
|
||||||
|
|
||||||
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 id=\"epub-highlight-{annotation.Id}\">{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 id=\"epub-highlight-{annotation.Id}\">{originalText}</app-epub-highlight>");
|
|
||||||
elem.RemoveAllChildren();
|
|
||||||
elem.AppendChild(highlightNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
|
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
|
||||||
{
|
{
|
||||||
var images = doc.DocumentNode.SelectNodes("//img")
|
var images = doc.DocumentNode.SelectNodes("//img")
|
||||||
|
|
@ -432,23 +365,6 @@ public partial class BookService : IBookService
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InjectImages(HtmlDocument doc, EpubBookRef book, string apiBase)
|
|
||||||
{
|
|
||||||
var images = doc.DocumentNode.SelectNodes("//img")
|
|
||||||
?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg");
|
|
||||||
|
|
||||||
if (images == null) return;
|
|
||||||
|
|
||||||
var parent = images[0].ParentNode;
|
|
||||||
|
|
||||||
foreach (var image in images)
|
|
||||||
{
|
|
||||||
// TODO: How do I make images clickable with state?
|
|
||||||
//image.AddClass("kavita-scale-width");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the image key associated with the file. Contains some basic fallback logic.
|
/// Returns the image key associated with the file. Contains some basic fallback logic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -957,50 +873,6 @@ public partial class BookService : IBookService
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath)
|
|
||||||
{
|
|
||||||
var ret = new Dictionary<int, int>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions);
|
|
||||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
|
||||||
|
|
||||||
var doc = new HtmlDocument {OptionFixNestedTags = true};
|
|
||||||
|
|
||||||
|
|
||||||
var bookPages = await book.GetReadingOrderAsync();
|
|
||||||
foreach (var contentFileRef in bookPages)
|
|
||||||
{
|
|
||||||
var page = mappings[contentFileRef.Key];
|
|
||||||
var content = await contentFileRef.ReadContentAsync();
|
|
||||||
doc.LoadHtml(content);
|
|
||||||
|
|
||||||
var body = doc.DocumentNode.SelectSingleNode("//body");
|
|
||||||
|
|
||||||
if (body == null)
|
|
||||||
{
|
|
||||||
_logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
|
|
||||||
doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("<body></body>"));
|
|
||||||
body = doc.DocumentNode.SelectSingleNode("//html/body");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all words in the html body
|
|
||||||
// TEMP: REfactor this to use WordCountAnalyzerService
|
|
||||||
var textNodes = body!.SelectNodes("//text()[not(parent::script)]");
|
|
||||||
ret.Add(page, textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "There was an issue calculating word counts per page");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
|
/// 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
|
/// then null is returned. This expects only an epub file
|
||||||
|
|
@ -1144,10 +1016,8 @@ public partial class BookService : IBookService
|
||||||
/// <param name="body">Body element from the epub</param>
|
/// <param name="body">Body element from the epub</param>
|
||||||
/// <param name="mappings">Epub mappings</param>
|
/// <param name="mappings">Epub mappings</param>
|
||||||
/// <param name="page">Page number we are loading</param>
|
/// <param name="page">Page number we are loading</param>
|
||||||
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
|
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body,
|
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page)
|
||||||
Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
|
|
||||||
{
|
{
|
||||||
await InlineStyles(doc, book, apiBase, body);
|
await InlineStyles(doc, book, apiBase, body);
|
||||||
|
|
||||||
|
|
@ -1155,13 +1025,6 @@ public partial class BookService : IBookService
|
||||||
|
|
||||||
ScopeImages(doc, book, apiBase);
|
ScopeImages(doc, book, apiBase);
|
||||||
|
|
||||||
InjectImages(doc, book, apiBase);
|
|
||||||
|
|
||||||
// Inject PTOC Bookmark Icons
|
|
||||||
InjectPTOCBookmarks(doc, book, ptocBookmarks);
|
|
||||||
|
|
||||||
InjectAnnotations(doc, book, annotations);
|
|
||||||
|
|
||||||
return PrepareFinalHtml(doc, body);
|
return PrepareFinalHtml(doc, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1226,88 +1089,6 @@ public partial class BookService : IBookService
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter)
|
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);
|
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
|
||||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||||
|
|
||||||
|
|
@ -1318,29 +1099,53 @@ public partial class BookService : IBookService
|
||||||
{
|
{
|
||||||
foreach (var navigationItem in navItems)
|
foreach (var navigationItem in navItems)
|
||||||
{
|
{
|
||||||
var tocItem = CreateToCChapterRecursively(book, navigationItem, mappings);
|
if (navigationItem.NestedItems.Count == 0)
|
||||||
if (tocItem != null)
|
|
||||||
{
|
{
|
||||||
chaptersList.Add(tocItem);
|
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 = new List<BookChapterItem>()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chaptersList.Count != 0) return chaptersList;
|
if (chaptersList.Count != 0) return chaptersList;
|
||||||
|
|
||||||
// Rest of your fallback logic remains the same...
|
|
||||||
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
|
// 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)
|
var tocPage = book.Content.Html.Local.Select(s => s.Key)
|
||||||
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||||
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
||||||
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
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;
|
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
|
||||||
var content = await file.ReadContentAsync();
|
var content = await file.ReadContentAsync();
|
||||||
|
|
||||||
var doc = new HtmlDocument();
|
var doc = new HtmlDocument();
|
||||||
doc.LoadHtml(content);
|
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");
|
var anchors = doc.DocumentNode.SelectNodes("//a");
|
||||||
if (anchors == null) return chaptersList;
|
if (anchors == null) return chaptersList;
|
||||||
|
|
||||||
|
|
@ -1361,55 +1166,19 @@ public partial class BookService : IBookService
|
||||||
Title = anchor.InnerText,
|
Title = anchor.InnerText,
|
||||||
Page = mappings[key],
|
Page = mappings[key],
|
||||||
Part = part,
|
Part = part,
|
||||||
Children = []
|
Children = new List<BookChapterItem>()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return chaptersList;
|
return chaptersList;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BookChapterItem? CreateToCChapterRecursively(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);
|
|
||||||
int? page = null;
|
|
||||||
if (!string.IsNullOrEmpty(key) && mappings.TryGetValue(key, out var mapping))
|
|
||||||
{
|
|
||||||
page = mapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively process nested items
|
|
||||||
var children = new List<BookChapterItem>();
|
|
||||||
if (navigationItem.NestedItems?.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (var nestedItem in navigationItem.NestedItems)
|
|
||||||
{
|
|
||||||
var childItem = CreateToCChapterRecursively(book, nestedItem, mappings);
|
|
||||||
if (childItem != null)
|
|
||||||
{
|
|
||||||
children.Add(childItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only create a BookChapterItem if we have a valid page or children
|
|
||||||
if (page.HasValue || children.Count > 0)
|
|
||||||
{
|
|
||||||
return new BookChapterItem
|
|
||||||
{
|
|
||||||
Title = navigationItem.Title ?? string.Empty,
|
|
||||||
Page = page ?? 0, // You might want to handle this differently
|
|
||||||
Part = navigationItem.Link?.Anchor ?? string.Empty,
|
|
||||||
Children = children
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountParentDirectory(string path)
|
private static int CountParentDirectory(string path)
|
||||||
{
|
{
|
||||||
return ParentDirectoryRegex().Matches(path).Count;
|
const string pattern = @"\.\./";
|
||||||
|
var matches = Regex.Matches(path, pattern);
|
||||||
|
|
||||||
|
return matches.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1446,8 +1215,7 @@ public partial class BookService : IBookService
|
||||||
/// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param>
|
/// <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>
|
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
|
||||||
/// <exception cref="KavitaException">All exceptions throw this</exception>
|
/// <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, List<AnnotationDto> annotations)
|
|
||||||
{
|
{
|
||||||
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
|
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
|
||||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||||
|
|
@ -1489,7 +1257,7 @@ public partial class BookService : IBookService
|
||||||
body = doc.DocumentNode.SelectSingleNode("/html/body");
|
body = doc.DocumentNode.SelectSingleNode("/html/body");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations);
|
return await ScopePage(doc, book, apiBase, body, mappings, page);
|
||||||
}
|
}
|
||||||
} catch (Exception ex)
|
} catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -1575,28 +1343,6 @@ public partial class BookService : IBookService
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? GetChapterTitleFromToC(ICollection<BookChapterItem>? tableOfContents, int pageNumber)
|
|
||||||
{
|
|
||||||
if (tableOfContents == null) return null;
|
|
||||||
|
|
||||||
foreach (var item in tableOfContents)
|
|
||||||
{
|
|
||||||
// Check if current item matches the page number
|
|
||||||
if (item.Page == pageNumber)
|
|
||||||
return item.Title;
|
|
||||||
|
|
||||||
// Recursively search children if they exist
|
|
||||||
if (item.Children?.Count > 0)
|
|
||||||
{
|
|
||||||
var childResult = GetChapterTitleFromToC(item.Children, pageNumber);
|
|
||||||
if (childResult != null)
|
|
||||||
return childResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
|
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
|
||||||
{
|
{
|
||||||
|
|
@ -1686,7 +1432,4 @@ public partial class BookService : IBookService
|
||||||
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
|
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex(@"\.\./")]
|
|
||||||
private static partial Regex ParentDirectoryRegex();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,11 @@ public class EmailService : IEmailService
|
||||||
smtpClient.Timeout = 20000;
|
smtpClient.Timeout = 20000;
|
||||||
var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None;
|
var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None;
|
||||||
|
|
||||||
|
await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl);
|
||||||
|
if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password))
|
||||||
|
{
|
||||||
|
await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password);
|
||||||
|
}
|
||||||
|
|
||||||
ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault;
|
ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault;
|
||||||
|
|
||||||
|
|
@ -441,12 +445,6 @@ public class EmailService : IEmailService
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl);
|
|
||||||
if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password))
|
|
||||||
{
|
|
||||||
await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password);
|
|
||||||
}
|
|
||||||
|
|
||||||
await smtpClient.SendAsync(email);
|
await smtpClient.SendAsync(email);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||||
private readonly IReaderService _readerService;
|
private readonly IReaderService _readerService;
|
||||||
private readonly IMediaErrorService _mediaErrorService;
|
private readonly IMediaErrorService _mediaErrorService;
|
||||||
|
|
||||||
public const int AverageCharactersPerWord = 5;
|
private const int AverageCharactersPerWord = 5;
|
||||||
|
|
||||||
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
|
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
|
||||||
ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
|
ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
|
||||||
|
|
@ -247,6 +247,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||||
_unitOfWork.MangaFileRepository.Update(file);
|
_unitOfWork.MangaFileRepository.Update(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
|
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -255,8 +256,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||||
doc.LoadHtml(await bookFile.ReadContentAsync());
|
doc.LoadHtml(await bookFile.ReadContentAsync());
|
||||||
|
|
||||||
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
|
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
|
||||||
var characterCount = textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0;
|
return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0;
|
||||||
return GetWordCount(characterCount);
|
|
||||||
}
|
}
|
||||||
catch (EpubContentException ex)
|
catch (EpubContentException ex)
|
||||||
{
|
{
|
||||||
|
|
@ -267,10 +267,4 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int GetWordCount(int characterCount)
|
|
||||||
{
|
|
||||||
if (characterCount == 0) return 0;
|
|
||||||
return characterCount / AverageCharactersPerWord;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ export interface IHasReadingTime {
|
||||||
avgHoursToRead: number;
|
avgHoursToRead: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ export interface PersonalToC {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
title: string;
|
title: string;
|
||||||
bookScrollId: string | undefined;
|
bookScrollId: string | undefined;
|
||||||
selectedText: string | null;
|
|
||||||
chapterTitle: string | null;
|
|
||||||
/* Ui Only */
|
/* Ui Only */
|
||||||
position: 0;
|
position: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {Pipe, PipeTransform} from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {TranslocoService} from "@jsverse/transloco";
|
import {TranslocoService} from "@jsverse/transloco";
|
||||||
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||||
|
import {DecimalPipe} from "@angular/common";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'readTimeLeft',
|
name: 'readTimeLeft',
|
||||||
|
|
@ -10,10 +11,10 @@ export class ReadTimeLeftPipe implements PipeTransform {
|
||||||
|
|
||||||
constructor(private readonly translocoService: TranslocoService) {}
|
constructor(private readonly translocoService: TranslocoService) {}
|
||||||
|
|
||||||
transform(readingTimeLeft: HourEstimateRange, includeLeftLabel = false): string {
|
transform(readingTimeLeft: HourEstimateRange): string {
|
||||||
const hoursLabel = readingTimeLeft.avgHours > 1
|
const hoursLabel = readingTimeLeft.avgHours > 1
|
||||||
? this.translocoService.translate(`read-time-pipe.hours${includeLeftLabel ? '-left' : ''}`)
|
? this.translocoService.translate('read-time-pipe.hours')
|
||||||
: this.translocoService.translate(`read-time-pipe.hour${includeLeftLabel ? '-left' : ''}`);
|
: this.translocoService.translate('read-time-pipe.hour');
|
||||||
|
|
||||||
const formattedHours = this.customRound(readingTimeLeft.avgHours);
|
const formattedHours = this.customRound(readingTimeLeft.avgHours);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import {ApplicationRef, ComponentRef, createComponent, EmbeddedViewRef, inject, Injectable} from '@angular/core';
|
|
||||||
import {
|
|
||||||
AnnotationCardComponent
|
|
||||||
} from '../book-reader/_components/_annotations/annotation-card/annotation-card.component';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class AnnotationCardService {
|
|
||||||
|
|
||||||
private readonly applicationRef = inject(ApplicationRef);
|
|
||||||
|
|
||||||
private componentRef?: ComponentRef<AnnotationCardComponent>;
|
|
||||||
|
|
||||||
show(config: {
|
|
||||||
position: any;
|
|
||||||
annotationText?: string;
|
|
||||||
createdDate?: Date;
|
|
||||||
onMouseEnter?: () => void;
|
|
||||||
onMouseLeave?: () => void;
|
|
||||||
}): ComponentRef<AnnotationCardComponent> {
|
|
||||||
// Remove existing card if present
|
|
||||||
this.hide();
|
|
||||||
|
|
||||||
// Create component using createComponent (Angular 13+ approach)
|
|
||||||
this.componentRef = createComponent(AnnotationCardComponent, {
|
|
||||||
environmentInjector: this.applicationRef.injector
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set inputs using signals
|
|
||||||
this.componentRef.setInput('position', config.position);
|
|
||||||
this.componentRef.setInput('annotationText', config.annotationText || 'This is test text');
|
|
||||||
this.componentRef.setInput('createdDate', config.createdDate || new Date());
|
|
||||||
|
|
||||||
// Set up event handlers
|
|
||||||
if (config.onMouseEnter) {
|
|
||||||
this.componentRef.instance.mouseEnter.subscribe(config.onMouseEnter);
|
|
||||||
}
|
|
||||||
if (config.onMouseLeave) {
|
|
||||||
this.componentRef.instance.mouseLeave.subscribe(config.onMouseLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach to application
|
|
||||||
this.applicationRef.attachView(this.componentRef.hostView);
|
|
||||||
|
|
||||||
// Append to body
|
|
||||||
const domElem = (this.componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
|
|
||||||
document.body.appendChild(domElem);
|
|
||||||
|
|
||||||
return this.componentRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
hide(): void {
|
|
||||||
if (this.componentRef) {
|
|
||||||
this.applicationRef.detachView(this.componentRef.hostView);
|
|
||||||
this.componentRef.destroy();
|
|
||||||
this.componentRef = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateHoverState(isHovered: boolean): void {
|
|
||||||
if (this.componentRef) {
|
|
||||||
this.componentRef.instance.isHovered.set(isHovered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import {inject, Injectable, signal} from '@angular/core';
|
|
||||||
import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request";
|
|
||||||
import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {
|
|
||||||
ViewAnnotationDrawerComponent
|
|
||||||
} from "../book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component";
|
|
||||||
import {
|
|
||||||
CreateAnnotationDrawerComponent
|
|
||||||
} from "../book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component";
|
|
||||||
import {
|
|
||||||
ViewBookmarkDrawerComponent
|
|
||||||
} from "../book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
|
|
||||||
import {
|
|
||||||
LoadPageEvent,
|
|
||||||
ViewTocDrawerComponent
|
|
||||||
} from "../book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component";
|
|
||||||
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
|
|
||||||
import {
|
|
||||||
EpubSettingDrawerComponent,
|
|
||||||
} from "../book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component";
|
|
||||||
import {ReadingProfile} from "../_models/preferences/reading-profiles";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Responsible for opening the different readers and providing any context needed. Handles closing or keeping a stack of menus open.
|
|
||||||
*/
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class EpubReaderMenuService {
|
|
||||||
|
|
||||||
private readonly offcanvasService = inject(NgbOffcanvas);
|
|
||||||
private readonly utilityService = inject(UtilityService);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded
|
|
||||||
*/
|
|
||||||
public readonly isDrawerOpen = signal<boolean>(false);
|
|
||||||
|
|
||||||
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest) {
|
|
||||||
const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''});
|
|
||||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
|
||||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
|
||||||
ref.componentInstance.createAnnotation.set(annotation);
|
|
||||||
|
|
||||||
this.isDrawerOpen.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
openViewAnnotationsDrawer(chapterId: number) {
|
|
||||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
|
||||||
this.offcanvasService.dismiss();
|
|
||||||
}
|
|
||||||
const ref = this.offcanvasService.open(ViewAnnotationDrawerComponent, {position: 'end', panelClass: ''});
|
|
||||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
|
||||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
|
||||||
|
|
||||||
this.isDrawerOpen.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
openViewTocDrawer(chapterId: number, callbackFn: (evt: LoadPageEvent | null) => void) {
|
|
||||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
|
||||||
this.offcanvasService.dismiss();
|
|
||||||
}
|
|
||||||
const ref = this.offcanvasService.open(ViewTocDrawerComponent, {position: 'end', panelClass: ''});
|
|
||||||
ref.componentInstance.chapterId.set(chapterId);
|
|
||||||
ref.componentInstance.loadPage.subscribe((res: LoadPageEvent | null) => {
|
|
||||||
// Check if we are on mobile to collapse the menu
|
|
||||||
if (this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Mobile) {
|
|
||||||
this.closeAll();
|
|
||||||
}
|
|
||||||
callbackFn(res);
|
|
||||||
});
|
|
||||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
|
||||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
|
||||||
|
|
||||||
this.isDrawerOpen.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
openViewBookmarksDrawer(chapterId: number) {
|
|
||||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
|
||||||
this.offcanvasService.dismiss();
|
|
||||||
}
|
|
||||||
const ref = this.offcanvasService.open(ViewBookmarkDrawerComponent, {position: 'end', panelClass: ''});
|
|
||||||
ref.componentInstance.chapterId.set(chapterId);
|
|
||||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
|
||||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
|
||||||
|
|
||||||
this.isDrawerOpen.set(true);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
openSettingsDrawer(chapterId: number, seriesId: number, readingProfile: ReadingProfile) {
|
|
||||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
|
||||||
this.offcanvasService.dismiss();
|
|
||||||
}
|
|
||||||
const ref = this.offcanvasService.open(EpubSettingDrawerComponent, {position: 'start', panelClass: ''});
|
|
||||||
ref.componentInstance.chapterId.set(chapterId);
|
|
||||||
ref.componentInstance.seriesId.set(seriesId);
|
|
||||||
ref.componentInstance.readingProfile.set(readingProfile);
|
|
||||||
|
|
||||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
|
||||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
|
||||||
|
|
||||||
this.isDrawerOpen.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAll() {
|
|
||||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
|
||||||
this.offcanvasService.dismiss();
|
|
||||||
}
|
|
||||||
this.setDrawerClosed();
|
|
||||||
}
|
|
||||||
|
|
||||||
setDrawerClosed() {
|
|
||||||
console.log('Drawer closed');
|
|
||||||
this.isDrawerOpen.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,693 +0,0 @@
|
||||||
import {computed, DestroyRef, effect, inject, Injectable, signal} from '@angular/core';
|
|
||||||
import {Observable, Subject} from 'rxjs';
|
|
||||||
import {bookColorThemes, PageStyle} from "../book-reader/_components/reader-settings/reader-settings.component";
|
|
||||||
import {ReadingDirection} from '../_models/preferences/reading-direction';
|
|
||||||
import {WritingStyle} from '../_models/preferences/writing-style';
|
|
||||||
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
|
|
||||||
import {FormControl, FormGroup} from "@angular/forms";
|
|
||||||
import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading-profiles";
|
|
||||||
import {BookService, FontFamily} from "../book-reader/_services/book.service";
|
|
||||||
import {ThemeService} from './theme.service';
|
|
||||||
import {ReadingProfileService} from "./reading-profile.service";
|
|
||||||
import {debounceTime, skip, tap} from "rxjs/operators";
|
|
||||||
import {BookTheme} from "../_models/preferences/book-theme";
|
|
||||||
import {DOCUMENT} from "@angular/common";
|
|
||||||
import {translate} from "@jsverse/transloco";
|
|
||||||
import {ToastrService} from "ngx-toastr";
|
|
||||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
|
||||||
|
|
||||||
export interface ReaderSettingUpdate {
|
|
||||||
setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme';
|
|
||||||
object: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class EpubReaderSettingsService {
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly bookService = inject(BookService);
|
|
||||||
private readonly themeService = inject(ThemeService);
|
|
||||||
private readonly readingProfileService = inject(ReadingProfileService);
|
|
||||||
private readonly toastr = inject(ToastrService);
|
|
||||||
private readonly document = inject(DOCUMENT);
|
|
||||||
|
|
||||||
// Core signals - these will be the single source of truth
|
|
||||||
private readonly _currentReadingProfile = signal<ReadingProfile | null>(null);
|
|
||||||
private readonly _parentReadingProfile = signal<ReadingProfile | null>(null);
|
|
||||||
private readonly _currentSeriesId = signal<number | null>(null);
|
|
||||||
private readonly _isInitialized = signal<boolean>(false);
|
|
||||||
|
|
||||||
// Settings signals
|
|
||||||
private readonly _pageStyles = signal<PageStyle>(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements
|
|
||||||
private readonly _readingDirection = signal<ReadingDirection>(ReadingDirection.LeftToRight);
|
|
||||||
private readonly _writingStyle = signal<WritingStyle>(WritingStyle.Horizontal);
|
|
||||||
private readonly _activeTheme = signal<BookTheme | undefined>(undefined);
|
|
||||||
private readonly _clickToPaginate = signal<boolean>(false);
|
|
||||||
private readonly _layoutMode = signal<BookPageLayoutMode>(BookPageLayoutMode.Default);
|
|
||||||
private readonly _immersiveMode = signal<boolean>(false);
|
|
||||||
private readonly _isFullscreen = signal<boolean>(false);
|
|
||||||
|
|
||||||
// Form will be managed separately but updated from signals
|
|
||||||
private settingsForm: FormGroup = new FormGroup({});
|
|
||||||
private fontFamilies: FontFamily[] = this.bookService.getFontFamilies();
|
|
||||||
private isUpdatingFromForm = false; // Flag to prevent infinite loops
|
|
||||||
|
|
||||||
// Event subject for component communication (keep this for now, can be converted to effect later)
|
|
||||||
private settingUpdateSubject = new Subject<ReaderSettingUpdate>();
|
|
||||||
|
|
||||||
// Public readonly signals
|
|
||||||
public readonly currentReadingProfile = this._currentReadingProfile.asReadonly();
|
|
||||||
public readonly parentReadingProfile = this._parentReadingProfile.asReadonly();
|
|
||||||
public readonly isInitialized = this._isInitialized.asReadonly();
|
|
||||||
|
|
||||||
// Settings as readonly signals
|
|
||||||
public readonly pageStyles = this._pageStyles.asReadonly();
|
|
||||||
public readonly readingDirection = this._readingDirection.asReadonly();
|
|
||||||
public readonly writingStyle = this._writingStyle.asReadonly();
|
|
||||||
public readonly activeTheme = this._activeTheme.asReadonly();
|
|
||||||
public readonly clickToPaginate = this._clickToPaginate.asReadonly();
|
|
||||||
public readonly layoutMode = this._layoutMode.asReadonly();
|
|
||||||
public readonly immersiveMode = this._immersiveMode.asReadonly();
|
|
||||||
public readonly isFullscreen = this._isFullscreen.asReadonly();
|
|
||||||
|
|
||||||
// Computed signals for derived state
|
|
||||||
public readonly canPromoteProfile = computed(() => {
|
|
||||||
const profile = this._currentReadingProfile();
|
|
||||||
return profile !== null && profile.kind === ReadingProfileKind.Implicit;
|
|
||||||
});
|
|
||||||
|
|
||||||
public readonly hasParentProfile = computed(() => {
|
|
||||||
return this._parentReadingProfile() !== null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep observable for now - can be converted to effect later
|
|
||||||
public readonly settingUpdates$ = this.settingUpdateSubject.asObservable();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Effect to update form when signals change (only when not updating from form)
|
|
||||||
effect(() => {
|
|
||||||
const profile = this._currentReadingProfile();
|
|
||||||
if (profile && this._isInitialized() && !this.isUpdatingFromForm) {
|
|
||||||
this.updateFormFromSignals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Effect to emit setting updates when signals change
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'pageStyle',
|
|
||||||
object: this._pageStyles()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'clickToPaginate',
|
|
||||||
object: this._clickToPaginate()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'layoutMode',
|
|
||||||
object: this._layoutMode()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'readingDirection',
|
|
||||||
object: this._readingDirection()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'writingStyle',
|
|
||||||
object: this._writingStyle()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'immersiveMode',
|
|
||||||
object: this._immersiveMode()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this._isInitialized()) return;
|
|
||||||
|
|
||||||
const theme = this._activeTheme();
|
|
||||||
if (theme) {
|
|
||||||
this.settingUpdateSubject.next({
|
|
||||||
setting: 'theme',
|
|
||||||
object: theme
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the service with a reading profile and series ID
|
|
||||||
*/
|
|
||||||
async initialize(seriesId: number, readingProfile: ReadingProfile): Promise<void> {
|
|
||||||
this._currentSeriesId.set(seriesId);
|
|
||||||
this._currentReadingProfile.set(readingProfile);
|
|
||||||
|
|
||||||
console.log('init, reading profile: ', readingProfile);
|
|
||||||
|
|
||||||
// Load parent profile if needed
|
|
||||||
if (readingProfile.kind === ReadingProfileKind.Implicit) {
|
|
||||||
try {
|
|
||||||
const parent = await this.readingProfileService.getForSeries(seriesId, true).toPromise();
|
|
||||||
this._parentReadingProfile.set(parent || null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load parent reading profile:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup defaults and update signals
|
|
||||||
this.setupDefaultsFromProfile(readingProfile);
|
|
||||||
this.setupSettingsForm();
|
|
||||||
|
|
||||||
// Set initial theme
|
|
||||||
const themeName = readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme;
|
|
||||||
this.setTheme(themeName, false);
|
|
||||||
|
|
||||||
// Mark as initialized - this will trigger effects to emit initial values
|
|
||||||
this._isInitialized.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup default values and update signals from profile
|
|
||||||
*/
|
|
||||||
private setupDefaultsFromProfile(profile: ReadingProfile): void {
|
|
||||||
// Set defaults if undefined
|
|
||||||
if (profile.bookReaderFontFamily === undefined) {
|
|
||||||
profile.bookReaderFontFamily = 'default';
|
|
||||||
}
|
|
||||||
if (profile.bookReaderFontSize === undefined || profile.bookReaderFontSize < 50) {
|
|
||||||
profile.bookReaderFontSize = 100;
|
|
||||||
}
|
|
||||||
if (profile.bookReaderLineSpacing === undefined || profile.bookReaderLineSpacing < 100) {
|
|
||||||
profile.bookReaderLineSpacing = 100;
|
|
||||||
}
|
|
||||||
if (profile.bookReaderMargin === undefined) {
|
|
||||||
profile.bookReaderMargin = 0;
|
|
||||||
}
|
|
||||||
if (profile.bookReaderReadingDirection === undefined) {
|
|
||||||
profile.bookReaderReadingDirection = ReadingDirection.LeftToRight;
|
|
||||||
}
|
|
||||||
if (profile.bookReaderWritingStyle === undefined) {
|
|
||||||
profile.bookReaderWritingStyle = WritingStyle.Horizontal;
|
|
||||||
}
|
|
||||||
if (profile.bookReaderLayoutMode === undefined) {
|
|
||||||
profile.bookReaderLayoutMode = BookPageLayoutMode.Default;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update signals from profile
|
|
||||||
this._readingDirection.set(profile.bookReaderReadingDirection);
|
|
||||||
this._writingStyle.set(profile.bookReaderWritingStyle);
|
|
||||||
this._clickToPaginate.set(profile.bookReaderTapToPaginate);
|
|
||||||
this._layoutMode.set(profile.bookReaderLayoutMode);
|
|
||||||
this._immersiveMode.set(profile.bookReaderImmersiveMode);
|
|
||||||
|
|
||||||
// Set up page styles
|
|
||||||
this.setPageStyles(
|
|
||||||
profile.bookReaderFontFamily,
|
|
||||||
profile.bookReaderFontSize + '%',
|
|
||||||
profile.bookReaderMargin + 'vw',
|
|
||||||
profile.bookReaderLineSpacing + '%'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current settings form (for components that need direct form access)
|
|
||||||
*/
|
|
||||||
getSettingsForm(): FormGroup {
|
|
||||||
return this.settingsForm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current reading profile
|
|
||||||
*/
|
|
||||||
getCurrentReadingProfile(): ReadingProfile | null {
|
|
||||||
return this._currentReadingProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get font families for UI
|
|
||||||
*/
|
|
||||||
getFontFamilies(): FontFamily[] {
|
|
||||||
return this.fontFamilies;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available themes
|
|
||||||
*/
|
|
||||||
getThemes(): BookTheme[] {
|
|
||||||
return bookColorThemes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle reading direction
|
|
||||||
*/
|
|
||||||
toggleReadingDirection(): void {
|
|
||||||
const current = this._readingDirection();
|
|
||||||
const newDirection = current === ReadingDirection.LeftToRight
|
|
||||||
? ReadingDirection.RightToLeft
|
|
||||||
: ReadingDirection.LeftToRight;
|
|
||||||
|
|
||||||
this._readingDirection.set(newDirection);
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle writing style
|
|
||||||
*/
|
|
||||||
toggleWritingStyle(): void {
|
|
||||||
const current = this._writingStyle();
|
|
||||||
const newStyle = current === WritingStyle.Horizontal
|
|
||||||
? WritingStyle.Vertical
|
|
||||||
: WritingStyle.Horizontal;
|
|
||||||
|
|
||||||
this._writingStyle.set(newStyle);
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set theme
|
|
||||||
*/
|
|
||||||
setTheme(themeName: string, update: boolean = true): void {
|
|
||||||
const theme = bookColorThemes.find(t => t.name === themeName);
|
|
||||||
if (theme) {
|
|
||||||
this._activeTheme.set(theme);
|
|
||||||
if (update) {
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLayoutMode(mode: BookPageLayoutMode): void {
|
|
||||||
this._layoutMode.set(mode);
|
|
||||||
// Update form control to keep in sync
|
|
||||||
this.settingsForm.get('layoutMode')?.setValue(mode, { emitEvent: false });
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateClickToPaginate(value: boolean): void {
|
|
||||||
this._clickToPaginate.set(value);
|
|
||||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(value, { emitEvent: false });
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateReadingDirection(value: ReadingDirection): void {
|
|
||||||
this._readingDirection.set(value);
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWritingStyle(value: WritingStyle) {
|
|
||||||
this._writingStyle.set(value);
|
|
||||||
this.debouncedUpdateProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFullscreen(value: boolean) {
|
|
||||||
this._isFullscreen.set(value);
|
|
||||||
this.settingUpdateSubject.next({ setting: 'fullscreen', object: null }); // TODO: Refactor into an effect
|
|
||||||
}
|
|
||||||
|
|
||||||
updateImmersiveMode(value: boolean): void {
|
|
||||||
this._immersiveMode.set(value);
|
|
||||||
if (value) {
|
|
||||||
this._clickToPaginate.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounced update method to prevent too many API calls
|
|
||||||
private updateTimeout: any;
|
|
||||||
private debouncedUpdateProfile(): void {
|
|
||||||
if (this.updateTimeout) {
|
|
||||||
clearTimeout(this.updateTimeout);
|
|
||||||
}
|
|
||||||
this.updateTimeout = setTimeout(() => {
|
|
||||||
this.updateImplicitProfile();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit fullscreen toggle event
|
|
||||||
*/
|
|
||||||
toggleFullscreen(): void {
|
|
||||||
this.updateFullscreen(!this._isFullscreen());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update parent reading profile preferences
|
|
||||||
*/
|
|
||||||
updateParentProfile(): void {
|
|
||||||
const currentRp = this._currentReadingProfile();
|
|
||||||
const seriesId = this._currentSeriesId();
|
|
||||||
if (!currentRp || currentRp.kind !== ReadingProfileKind.Implicit || !seriesId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.readingProfileService.updateParentProfile(seriesId, this.packReadingProfile())
|
|
||||||
.subscribe(newProfile => {
|
|
||||||
this._currentReadingProfile.set(newProfile);
|
|
||||||
this.toastr.success(translate('manga-reader.reading-profile-updated'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promote implicit profile to named profile
|
|
||||||
*/
|
|
||||||
promoteProfile(): Observable<ReadingProfile> {
|
|
||||||
const currentRp = this._currentReadingProfile();
|
|
||||||
if (!currentRp || currentRp.kind !== ReadingProfileKind.Implicit) {
|
|
||||||
throw new Error('Can only promote implicit profiles');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.readingProfileService.promoteProfile(currentRp.id).pipe(
|
|
||||||
tap(newProfile => {
|
|
||||||
this._currentReadingProfile.set(newProfile);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update form controls from current signal values
|
|
||||||
*/
|
|
||||||
private updateFormFromSignals(): void {
|
|
||||||
const profile = this._currentReadingProfile();
|
|
||||||
if (!profile) return;
|
|
||||||
|
|
||||||
// Update form controls without triggering valueChanges
|
|
||||||
this.settingsForm.patchValue({
|
|
||||||
bookReaderFontFamily: profile.bookReaderFontFamily,
|
|
||||||
bookReaderFontSize: profile.bookReaderFontSize,
|
|
||||||
bookReaderTapToPaginate: this._clickToPaginate(),
|
|
||||||
bookReaderLineSpacing: profile.bookReaderLineSpacing,
|
|
||||||
bookReaderMargin: profile.bookReaderMargin,
|
|
||||||
layoutMode: this._layoutMode(),
|
|
||||||
bookReaderImmersiveMode: this._immersiveMode()
|
|
||||||
}, { emitEvent: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up the reactive form and bidirectional binding with signals
|
|
||||||
*/
|
|
||||||
private setupSettingsForm(): void {
|
|
||||||
const profile = this._currentReadingProfile();
|
|
||||||
if (!profile) return;
|
|
||||||
|
|
||||||
// Clear existing form
|
|
||||||
this.settingsForm = new FormGroup({});
|
|
||||||
|
|
||||||
// Add controls with current values
|
|
||||||
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(profile.bookReaderFontFamily));
|
|
||||||
this.settingsForm.addControl('bookReaderFontSize', new FormControl(profile.bookReaderFontSize));
|
|
||||||
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this._clickToPaginate()));
|
|
||||||
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(profile.bookReaderLineSpacing));
|
|
||||||
this.settingsForm.addControl('bookReaderMargin', new FormControl(profile.bookReaderMargin));
|
|
||||||
this.settingsForm.addControl('layoutMode', new FormControl(this._layoutMode()));
|
|
||||||
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this._immersiveMode()));
|
|
||||||
|
|
||||||
// Set up value change subscriptions
|
|
||||||
this.setupFormSubscriptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up form value change subscriptions to update signals
|
|
||||||
*/
|
|
||||||
private setupFormSubscriptions(): void {
|
|
||||||
// Font family changes
|
|
||||||
this.settingsForm.get('bookReaderFontFamily')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(fontName => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
|
|
||||||
const familyName = this.fontFamilies.find(f => f.title === fontName)?.family || 'default';
|
|
||||||
const currentStyles = this._pageStyles();
|
|
||||||
|
|
||||||
const newStyles = { ...currentStyles };
|
|
||||||
if (familyName === 'default') {
|
|
||||||
newStyles['font-family'] = 'inherit';
|
|
||||||
} else {
|
|
||||||
newStyles['font-family'] = `'${familyName}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._pageStyles.set(newStyles);
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Font size changes
|
|
||||||
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(value => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
|
|
||||||
const currentStyles = this._pageStyles();
|
|
||||||
const newStyles = { ...currentStyles };
|
|
||||||
newStyles['font-size'] = value + '%';
|
|
||||||
this._pageStyles.set(newStyles);
|
|
||||||
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tap to paginate changes
|
|
||||||
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(value => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
this._clickToPaginate.set(value);
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Line spacing changes
|
|
||||||
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(value => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
|
|
||||||
const currentStyles = this._pageStyles();
|
|
||||||
const newStyles = { ...currentStyles };
|
|
||||||
newStyles['line-height'] = value + '%';
|
|
||||||
this._pageStyles.set(newStyles);
|
|
||||||
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Margin changes
|
|
||||||
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(value => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
|
|
||||||
const currentStyles = this._pageStyles();
|
|
||||||
const newStyles = { ...currentStyles };
|
|
||||||
newStyles['margin-left'] = value + 'vw';
|
|
||||||
newStyles['margin-right'] = value + 'vw';
|
|
||||||
this._pageStyles.set(newStyles);
|
|
||||||
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Layout mode changes
|
|
||||||
this.settingsForm.get('layoutMode')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe((layoutMode: BookPageLayoutMode) => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
this._layoutMode.set(layoutMode);
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Immersive mode changes
|
|
||||||
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe((immersiveMode: boolean) => {
|
|
||||||
this.isUpdatingFromForm = true;
|
|
||||||
|
|
||||||
if (immersiveMode) {
|
|
||||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true, { emitEvent: false });
|
|
||||||
this._clickToPaginate.set(true);
|
|
||||||
}
|
|
||||||
this._immersiveMode.set(immersiveMode);
|
|
||||||
|
|
||||||
this.isUpdatingFromForm = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update implicit profile on form changes (debounced) - ONLY source of profile updates
|
|
||||||
this.settingsForm.valueChanges.pipe(
|
|
||||||
debounceTime(500), // Increased debounce time
|
|
||||||
skip(1), // Skip initial form creation
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(() => {
|
|
||||||
// Only update if we're not currently updating from form changes
|
|
||||||
if (!this.isUpdatingFromForm) {
|
|
||||||
this.updateImplicitProfile();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets a selection of settings to their default (Page Styles)
|
|
||||||
*/
|
|
||||||
resetSettings() {
|
|
||||||
const defaultStyles = this.getDefaultPageStyles();
|
|
||||||
this.setPageStyles(
|
|
||||||
defaultStyles["font-family"],
|
|
||||||
defaultStyles["font-size"],
|
|
||||||
defaultStyles['margin-left'],
|
|
||||||
defaultStyles['line-height'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// private emitInitialSettings(): void {
|
|
||||||
// // Emit all current settings so the reader can initialize properly
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'pageStyle', object: this.pageStylesSubject.value });
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'clickToPaginate', object: this.clickToPaginateSubject.value });
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'layoutMode', object: this.layoutModeSubject.value });
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'readingDirection', object: this.readingDirectionSubject.value });
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'writingStyle', object: this.writingStyleSubject.value });
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'immersiveMode', object: this.immersiveModeSubject.value });
|
|
||||||
//
|
|
||||||
// const activeTheme = this.activeThemeSubject.value;
|
|
||||||
// if (activeTheme) {
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'theme', object: activeTheme });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
private updateImplicitProfile(): void {
|
|
||||||
if (!this._currentReadingProfile() || !this._currentSeriesId()) return;
|
|
||||||
|
|
||||||
this.readingProfileService.updateImplicit(this.packReadingProfile(), this._currentSeriesId()!)
|
|
||||||
.subscribe({
|
|
||||||
next: newProfile => {
|
|
||||||
this._currentReadingProfile.set(newProfile);
|
|
||||||
},
|
|
||||||
error: err => {
|
|
||||||
console.error('Failed to update implicit profile:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Packs current settings into a ReadingProfile object
|
|
||||||
*/
|
|
||||||
private packReadingProfile(): ReadingProfile {
|
|
||||||
const currentProfile = this._currentReadingProfile();
|
|
||||||
if (!currentProfile) {
|
|
||||||
throw new Error('No current reading profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelSettings = this.settingsForm.getRawValue();
|
|
||||||
const data = { ...currentProfile };
|
|
||||||
|
|
||||||
// Update from form values
|
|
||||||
data.bookReaderFontFamily = modelSettings.bookReaderFontFamily;
|
|
||||||
data.bookReaderFontSize = modelSettings.bookReaderFontSize;
|
|
||||||
data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing;
|
|
||||||
data.bookReaderMargin = modelSettings.bookReaderMargin;
|
|
||||||
data.bookReaderTapToPaginate = this._clickToPaginate();
|
|
||||||
data.bookReaderLayoutMode = this._layoutMode();
|
|
||||||
data.bookReaderImmersiveMode = this._immersiveMode();
|
|
||||||
|
|
||||||
// Update from signals
|
|
||||||
data.bookReaderReadingDirection = this._readingDirection();
|
|
||||||
data.bookReaderWritingStyle = this._writingStyle();
|
|
||||||
|
|
||||||
const activeTheme = this._activeTheme();
|
|
||||||
if (activeTheme) {
|
|
||||||
data.bookReaderThemeName = activeTheme.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('packed reading profile:', data);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// private setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string): void {
|
|
||||||
// const windowWidth = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth;
|
|
||||||
// const mobileBreakpointMarginOverride = 700;
|
|
||||||
//
|
|
||||||
// let defaultMargin = '15vw';
|
|
||||||
// if (windowWidth <= mobileBreakpointMarginOverride) {
|
|
||||||
// defaultMargin = '5vw';
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const currentStyles = this.pageStylesSubject.value;
|
|
||||||
// const newStyles: PageStyle = {
|
|
||||||
// 'font-family': fontFamily || currentStyles['font-family'] || 'default',
|
|
||||||
// 'font-size': fontSize || currentStyles['font-size'] || '100%',
|
|
||||||
// 'margin-left': margin || currentStyles['margin-left'] || defaultMargin,
|
|
||||||
// 'margin-right': margin || currentStyles['margin-right'] || defaultMargin,
|
|
||||||
// 'line-height': lineHeight || currentStyles['line-height'] || '100%'
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// this.pageStylesSubject.next(newStyles);
|
|
||||||
// this.updateImplicitProfile();
|
|
||||||
// this.settingUpdateSubject.next({ setting: 'pageStyle', object: newStyles });
|
|
||||||
// }
|
|
||||||
private setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string): void {
|
|
||||||
const windowWidth = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth;
|
|
||||||
const mobileBreakpointMarginOverride = 700;
|
|
||||||
|
|
||||||
let defaultMargin = '15vw';
|
|
||||||
if (windowWidth <= mobileBreakpointMarginOverride) {
|
|
||||||
defaultMargin = '5vw';
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStyles = this._pageStyles();
|
|
||||||
const newStyles: PageStyle = {
|
|
||||||
'font-family': fontFamily || currentStyles['font-family'] || 'default',
|
|
||||||
'font-size': fontSize || currentStyles['font-size'] || '100%',
|
|
||||||
'margin-left': margin || currentStyles['margin-left'] || defaultMargin,
|
|
||||||
'margin-right': margin || currentStyles['margin-right'] || defaultMargin,
|
|
||||||
'line-height': lineHeight || currentStyles['line-height'] || '100%'
|
|
||||||
};
|
|
||||||
|
|
||||||
this._pageStyles.set(newStyles);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDefaultPageStyles(): PageStyle {
|
|
||||||
return {
|
|
||||||
'font-family': 'default',
|
|
||||||
'font-size': '100%',
|
|
||||||
'margin-left': '15vw',
|
|
||||||
'margin-right': '15vw',
|
|
||||||
'line-height': '100%'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
createNewProfileFromImplicit() {
|
|
||||||
const rp = this.getCurrentReadingProfile();
|
|
||||||
if (rp === null || rp.kind !== ReadingProfileKind.Implicit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.promoteProfile().subscribe(newProfile => {
|
|
||||||
this._currentReadingProfile.set(newProfile);
|
|
||||||
this._parentReadingProfile.set(newProfile);
|
|
||||||
this.toastr.success(translate("manga-reader.reading-profile-promoted"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,8 +24,6 @@ import {UtilityService} from "../shared/_services/utility.service";
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||||
import {Annotation} from "../book-reader/_models/annotation";
|
|
||||||
import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request";
|
|
||||||
|
|
||||||
|
|
||||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||||
|
|
@ -224,10 +222,6 @@ export class ReaderService {
|
||||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
|
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getTimeLeftForChapter(seriesId: number, chapterId: number) {
|
|
||||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + `reader/time-left-for-chapter?seriesId=${seriesId}&chapterId=${chapterId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
|
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
|
||||||
*/
|
*/
|
||||||
|
|
@ -332,16 +326,8 @@ export class ReaderService {
|
||||||
return this.httpClient.get<Array<PersonalToC>>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId);
|
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, selectedText: string) {
|
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, selectedText});
|
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId});
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotations(chapterId: number) {
|
|
||||||
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'reader/annotations?chapterId=' + chapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
createAnnotation(data: CreateAnnotationRequest) {
|
|
||||||
return this.httpClient.post<Array<Annotation>>(this.baseUrl + 'reader/create-annotation', data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getElementFromXPath(path: string) {
|
getElementFromXPath(path: string) {
|
||||||
|
|
@ -404,5 +390,4 @@ export class ReaderService {
|
||||||
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
|
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
|
||||||
{queryParams: {incognitoMode}});
|
{queryParams: {incognitoMode}});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<div class="annotation-card"
|
|
||||||
[style.top.px]="position().top"
|
|
||||||
[style.left.px]="position().left"
|
|
||||||
[attr.data-position]="position().isRight ? 'right' : 'left'"
|
|
||||||
[class.hovered]="isHovered()"
|
|
||||||
(mouseenter)="onMouseEnter()"
|
|
||||||
(mouseleave)="onMouseLeave()">
|
|
||||||
|
|
||||||
<div class="annotation-content">
|
|
||||||
<div class="annotation-text">{{ annotationText() }}</div>
|
|
||||||
<div class="annotation-meta">
|
|
||||||
<small>{{ createdDate() | utcToLocaleDate | date:'short' }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="connection-line"
|
|
||||||
[class.hovered]="isHovered()"
|
|
||||||
[style.left.px]="position().connection.startX"
|
|
||||||
[style.top.px]="position().connection.startY"
|
|
||||||
[style.width.px]="position().connection.distance"
|
|
||||||
[style.transform]="'rotate(' + position().connection.angle + 'deg)'"
|
|
||||||
[style.opacity]="isHovered() ? 1 : 0.3">
|
|
||||||
<div class="connection-dot"></div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// annotation-card.component.scss
|
|
||||||
.annotation-card {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
|
||||||
width: 300px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.annotation-content {
|
|
||||||
.annotation-text {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annotation-meta {
|
|
||||||
color: #6b7280;
|
|
||||||
border-top: 1px solid #f3f4f6;
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-line {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 999;
|
|
||||||
height: 2px;
|
|
||||||
background-color: #9ca3af;
|
|
||||||
transform-origin: 0 50%;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
|
|
||||||
.connection-dot {
|
|
||||||
position: absolute;
|
|
||||||
right: -3px;
|
|
||||||
top: 50%;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background-color: #9ca3af;
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.hovered {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import {Component, input, model, output} from '@angular/core';
|
|
||||||
import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe";
|
|
||||||
import {DatePipe} from "@angular/common";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-annotation-card',
|
|
||||||
imports: [
|
|
||||||
UtcToLocaleDatePipe,
|
|
||||||
DatePipe
|
|
||||||
],
|
|
||||||
templateUrl: './annotation-card.component.html',
|
|
||||||
styleUrl: './annotation-card.component.scss'
|
|
||||||
})
|
|
||||||
export class AnnotationCardComponent {
|
|
||||||
position = input.required<any>();
|
|
||||||
annotationText = input<string>('This is test text');
|
|
||||||
createdDate = input<string>('01-01-0001');
|
|
||||||
isHovered = model<boolean>(false);
|
|
||||||
|
|
||||||
mouseEnter = output<void>();
|
|
||||||
mouseLeave = output<void>();
|
|
||||||
|
|
||||||
onMouseEnter() {
|
|
||||||
this.mouseEnter.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseLeave() {
|
|
||||||
this.mouseLeave.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<!--<span class="epub-highlight">-->
|
|
||||||
<!-- <i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>-->
|
|
||||||
<!-- <span [class]="highlightClasses()" [attr.data-highlight-color]="color()">-->
|
|
||||||
<!-- <ng-content />-->
|
|
||||||
<!-- </span>-->
|
|
||||||
<!--</span>-->
|
|
||||||
|
|
||||||
<span class="epub-highlight"
|
|
||||||
#highlightSpan
|
|
||||||
(mouseenter)="onMouseEnter()"
|
|
||||||
(mouseleave)="onMouseLeave()">
|
|
||||||
<i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>
|
|
||||||
<span [class]="highlightClasses()" [attr.data-highlight-color]="color()">
|
|
||||||
<ng-content />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
.epub-highlight {
|
|
||||||
position: relative;
|
|
||||||
display: inline;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
|
|
||||||
.epub-highlight-blue {
|
|
||||||
background-color: rgba(59, 130, 246, 0.3);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(59, 130, 246, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.epub-highlight-green {
|
|
||||||
background-color: rgba(34, 197, 94, 0.3);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(34, 197, 94, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global styles for annotation cards (since they're appended to body)
|
|
||||||
::ng-deep .annotation-card,
|
|
||||||
.annotation-card {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
|
||||||
width: 200px;
|
|
||||||
//background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&[data-position="left"] {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-position="right"] {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.annotation-content {
|
|
||||||
.annotation-text {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annotation-meta {
|
|
||||||
color: #6b7280;
|
|
||||||
border-top: 1px solid #f3f4f6;
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-line {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 2px;
|
|
||||||
background-color: #9ca3af;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
opacity: 0.3; // Default low opacity
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
|
|
||||||
// Show line on hover
|
|
||||||
&.hovered {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-direction="left"] {
|
|
||||||
right: -20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-direction="right"] {
|
|
||||||
left: -20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background-color: #9ca3af;
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-direction="left"]::after {
|
|
||||||
right: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-direction="right"]::after {
|
|
||||||
left: -3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
import {
|
|
||||||
AfterViewChecked,
|
|
||||||
Component,
|
|
||||||
ComponentRef,
|
|
||||||
computed,
|
|
||||||
ElementRef,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
model,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
signal,
|
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
|
||||||
import {Annotation} from "../../../_models/annotation";
|
|
||||||
import {AnnotationCardComponent} from "../annotation-card/annotation-card.component";
|
|
||||||
import {AnnotationCardService} from 'src/app/_service/annotation-card.service';
|
|
||||||
|
|
||||||
export type HighlightColor = 'blue' | 'green';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-epub-highlight',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './epub-highlight.component.html',
|
|
||||||
styleUrl: './epub-highlight.component.scss'
|
|
||||||
})
|
|
||||||
export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
||||||
showHighlight = model<boolean>(false);
|
|
||||||
color = input<HighlightColor>('blue');
|
|
||||||
annotation = model.required<Annotation | null>();
|
|
||||||
isHovered = signal<boolean>(false);
|
|
||||||
|
|
||||||
@ViewChild('highlightSpan', { static: false }) highlightSpan!: ElementRef;
|
|
||||||
|
|
||||||
private resizeObserver?: ResizeObserver;
|
|
||||||
private annotationCardElement?: HTMLElement;
|
|
||||||
private annotationCardRef?: ComponentRef<AnnotationCardComponent>;
|
|
||||||
|
|
||||||
private annotationCardService = inject(AnnotationCardService);
|
|
||||||
|
|
||||||
showAnnotationCard = computed(() => {
|
|
||||||
const annotation = this.annotation();
|
|
||||||
return this.showHighlight() && true; //annotation && annotation?.noteText.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
highlightClasses = computed(() => {
|
|
||||||
const baseClass = 'epub-highlight';
|
|
||||||
|
|
||||||
if (!this.showHighlight()) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorClass = `epub-highlight-${this.color()}`;
|
|
||||||
return `${colorClass}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
cardPosition = computed(() => {
|
|
||||||
console.log('card position called')
|
|
||||||
if (!this.showHighlight() || !this.highlightSpan) return null;
|
|
||||||
|
|
||||||
const rect = this.highlightSpan.nativeElement.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const cardWidth = 200;
|
|
||||||
const cardHeight = 80; // Approximate card height
|
|
||||||
|
|
||||||
// Check if highlight is on left half (< 50%) or right half (>= 50%) of document
|
|
||||||
const highlightCenterX = rect.left + (rect.width / 2);
|
|
||||||
const isOnLeftHalf = highlightCenterX < (viewportWidth * 0.5);
|
|
||||||
|
|
||||||
const cardLeft = isOnLeftHalf
|
|
||||||
? Math.max(20, rect.left - cardWidth - 20) // Left side with margin consideration
|
|
||||||
: Math.min(viewportWidth - cardWidth - 20, rect.right + 20); // Right side
|
|
||||||
|
|
||||||
const cardTop = rect.top + window.scrollY;
|
|
||||||
|
|
||||||
// Calculate connection points
|
|
||||||
const highlightCenterY = rect.top + window.scrollY + (rect.height / 2);
|
|
||||||
const cardCenterY = cardTop + (cardHeight / 2);
|
|
||||||
|
|
||||||
// Connection points
|
|
||||||
const highlightPoint = {
|
|
||||||
x: isOnLeftHalf ? rect.left : rect.right,
|
|
||||||
y: highlightCenterY
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardPoint = {
|
|
||||||
x: isOnLeftHalf ? cardLeft + cardWidth : cardLeft,
|
|
||||||
y: cardCenterY
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate line properties
|
|
||||||
const deltaX = cardPoint.x - highlightPoint.x;
|
|
||||||
const deltaY = cardPoint.y - highlightPoint.y;
|
|
||||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
||||||
const angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: cardTop,
|
|
||||||
left: cardLeft,
|
|
||||||
isRight: !isOnLeftHalf,
|
|
||||||
connection: {
|
|
||||||
startX: highlightPoint.x,
|
|
||||||
startY: highlightPoint.y,
|
|
||||||
endX: cardPoint.x,
|
|
||||||
endY: cardPoint.y,
|
|
||||||
distance: distance,
|
|
||||||
angle: angle
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
// Monitor viewport changes for repositioning
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
|
||||||
// Trigger recalculation if card is visible
|
|
||||||
if (this.showAnnotationCard()) {
|
|
||||||
this.updateCardPosition();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.resizeObserver.observe(document.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewChecked() {
|
|
||||||
if (this.showAnnotationCard() && this.cardPosition()) {
|
|
||||||
this.createOrUpdateAnnotationCard();
|
|
||||||
} else {
|
|
||||||
this.removeAnnotationCard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy() {
|
|
||||||
this.resizeObserver?.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseEnter() {
|
|
||||||
this.isHovered.set(true);
|
|
||||||
if (this.annotation() && this.showAnnotationCard()) {
|
|
||||||
//this.showAnnotationCard.update(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseLeave() {
|
|
||||||
this.isHovered.set(false);
|
|
||||||
//this.showAnnotationCard.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
toggleHighlight() {
|
|
||||||
this.showHighlight.set(!this.showHighlight());
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCardPosition() {
|
|
||||||
// TODO: Figure this out
|
|
||||||
}
|
|
||||||
|
|
||||||
private createOrUpdateAnnotationCard() {
|
|
||||||
// const pos = this.cardPosition();
|
|
||||||
// if (!pos) return;
|
|
||||||
//
|
|
||||||
// // Remove existing card if it exists
|
|
||||||
// this.removeAnnotationCard();
|
|
||||||
//
|
|
||||||
// // Create new card element
|
|
||||||
// this.annotationCardElement = document.createElement('div');
|
|
||||||
// this.annotationCardElement.className = `annotation-card ${this.isHovered() ? 'hovered' : ''}`;
|
|
||||||
// this.annotationCardElement.setAttribute('data-position', pos.isRight ? 'right' : 'left');
|
|
||||||
// this.annotationCardElement.style.position = 'absolute';
|
|
||||||
// this.annotationCardElement.style.top = `${pos.top}px`;
|
|
||||||
// this.annotationCardElement.style.left = `${pos.left}px`;
|
|
||||||
// this.annotationCardElement.style.zIndex = '1000';
|
|
||||||
//
|
|
||||||
// // Add event listeners for hover
|
|
||||||
// this.annotationCardElement.addEventListener('mouseenter', () => this.onMouseEnter());
|
|
||||||
// this.annotationCardElement.addEventListener('mouseleave', () => this.onMouseLeave());
|
|
||||||
//
|
|
||||||
// // Create card content
|
|
||||||
// this.annotationCardElement.innerHTML = `
|
|
||||||
// <div class="annotation-content">
|
|
||||||
// <div class="annotation-text">This is test text</div>
|
|
||||||
// <div class="annotation-meta">
|
|
||||||
// <small>10/20/2025</small>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// `;
|
|
||||||
//
|
|
||||||
// // Create connection line
|
|
||||||
// const lineElement = document.createElement('div');
|
|
||||||
// lineElement.className = `connection-line ${this.isHovered() ? 'hovered' : ''}`;
|
|
||||||
// lineElement.style.position = 'absolute';
|
|
||||||
// lineElement.style.left = `${pos.connection.startX}px`;
|
|
||||||
// lineElement.style.top = `${pos.connection.startY}px`;
|
|
||||||
// lineElement.style.width = `${pos.connection.distance}px`;
|
|
||||||
// lineElement.style.height = '2px';
|
|
||||||
// lineElement.style.backgroundColor = '#9ca3af';
|
|
||||||
// lineElement.style.transformOrigin = '0 50%';
|
|
||||||
// lineElement.style.transform = `rotate(${pos.connection.angle}deg)`;
|
|
||||||
// lineElement.style.opacity = this.isHovered() ? '1' : '0.3';
|
|
||||||
// lineElement.style.transition = 'opacity 0.2s ease';
|
|
||||||
// lineElement.style.zIndex = '999';
|
|
||||||
//
|
|
||||||
// // Add dot at the end
|
|
||||||
// const dotElement = document.createElement('div');
|
|
||||||
// dotElement.style.position = 'absolute';
|
|
||||||
// dotElement.style.right = '-3px';
|
|
||||||
// dotElement.style.top = '50%';
|
|
||||||
// dotElement.style.width = '6px';
|
|
||||||
// dotElement.style.height = '6px';
|
|
||||||
// dotElement.style.backgroundColor = '#9ca3af';
|
|
||||||
// dotElement.style.borderRadius = '50%';
|
|
||||||
// dotElement.style.transform = 'translateY(-50%)';
|
|
||||||
//
|
|
||||||
// lineElement.appendChild(dotElement);
|
|
||||||
//
|
|
||||||
// // Append to body
|
|
||||||
// document.body.appendChild(this.annotationCardElement);
|
|
||||||
// document.body.appendChild(lineElement);
|
|
||||||
//
|
|
||||||
// // Store reference to line for updates
|
|
||||||
// (this.annotationCardElement as any).lineElement = lineElement;
|
|
||||||
|
|
||||||
const pos = this.cardPosition();
|
|
||||||
if (!pos) return;
|
|
||||||
|
|
||||||
// Only create if not already created
|
|
||||||
if (!this.annotationCardRef) {
|
|
||||||
this.annotationCardRef = this.annotationCardService.show({
|
|
||||||
position: pos,
|
|
||||||
annotationText: this.annotation()?.comment,
|
|
||||||
createdDate: new Date('10/20/2025'),
|
|
||||||
onMouseEnter: () => this.onMouseEnter(),
|
|
||||||
onMouseLeave: () => this.onMouseLeave()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeAnnotationCard() {
|
|
||||||
// if (this.annotationCardElement) {
|
|
||||||
// // Remove associated line element
|
|
||||||
// const lineElement = (this.annotationCardElement as any).lineElement;
|
|
||||||
// if (lineElement) {
|
|
||||||
// lineElement.remove();
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// this.annotationCardElement.remove();
|
|
||||||
// this.annotationCardElement = undefined;
|
|
||||||
// }
|
|
||||||
if (this.annotationCardRef) {
|
|
||||||
this.annotationCardService.hide();
|
|
||||||
this.annotationCardRef = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<ng-container *transloco="let t; prefix: 'create-annotation-drawer'">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 class="offcanvas-title">
|
|
||||||
{{t('title')}}
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="offcanvas-body">
|
|
||||||
|
|
||||||
Hello
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// You must add this on a component based drawer
|
|
||||||
:host {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model} from '@angular/core';
|
|
||||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
|
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-create-annotation-drawer',
|
|
||||||
imports: [
|
|
||||||
TranslocoDirective
|
|
||||||
],
|
|
||||||
templateUrl: './create-annotation-drawer.component.html',
|
|
||||||
styleUrl: './create-annotation-drawer.component.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class CreateAnnotationDrawerComponent {
|
|
||||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
createAnnotation = model<CreateAnnotationRequest | null>(null);
|
|
||||||
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.activeOffcanvas.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<ng-container *transloco="let t; prefix: 'epub-setting-drawer'">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 class="offcanvas-title">
|
|
||||||
{{t('title')}}
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="offcanvas-body">
|
|
||||||
@let sId = seriesId();
|
|
||||||
@let rp = readingProfile();
|
|
||||||
@if (sId && rp) {
|
|
||||||
<app-reader-settings
|
|
||||||
[seriesId]="sId"
|
|
||||||
[readingProfile]="rp"
|
|
||||||
></app-reader-settings>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// You must add this on a component based drawer
|
|
||||||
:host {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core';
|
|
||||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {ReaderSettingsComponent} from "../../reader-settings/reader-settings.component";
|
|
||||||
import {ReadingProfile} from "../../../../_models/preferences/reading-profiles";
|
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-epub-setting-drawer',
|
|
||||||
imports: [
|
|
||||||
ReaderSettingsComponent,
|
|
||||||
TranslocoDirective
|
|
||||||
],
|
|
||||||
templateUrl: './epub-setting-drawer.component.html',
|
|
||||||
styleUrl: './epub-setting-drawer.component.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class EpubSettingDrawerComponent {
|
|
||||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
chapterId = model<number>();
|
|
||||||
seriesId = model<number>();
|
|
||||||
readingProfile = model<ReadingProfile>();
|
|
||||||
|
|
||||||
// updated = new EventEmitter<ReaderSettingUpdate>();
|
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
const id = this.chapterId();
|
|
||||||
if (!id) {
|
|
||||||
console.error('You must pass chapterId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// updateColorTheme(theme: BookTheme) {
|
|
||||||
// const evt = {setting: 'theme', object: theme} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// updateReaderStyles(pageStyles: PageStyle) {
|
|
||||||
// const evt = {setting: 'pageStyle', object: pageStyles} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// showPaginationOverlay(clickToPaginate: boolean) {
|
|
||||||
// const evt = {setting: 'clickToPaginate', object: clickToPaginate} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// toggleFullscreen() {
|
|
||||||
// const evt = {setting: 'fullscreen', object: null} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// updateWritingStyle(writingStyle: WritingStyle) {
|
|
||||||
// const evt = {setting: 'writingStyle', object: writingStyle} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// updateLayoutMode(mode: BookPageLayoutMode) {
|
|
||||||
// const evt = {setting: 'layoutMode', object: mode} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// updateReadingDirection(readingDirection: ReadingDirection) {
|
|
||||||
// const evt = {setting: 'readingDirection', object: readingDirection} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// updateImmersiveMode(immersiveMode: boolean) {
|
|
||||||
// const evt = {setting: 'immersiveMode', object: immersiveMode} as ReaderSettingUpdate;
|
|
||||||
// this.updated.emit(evt);
|
|
||||||
// }
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.activeOffcanvas.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<ng-container *transloco="let t; prefix: 'view-annotation-drawer'">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 class="offcanvas-title">
|
|
||||||
{{t('title')}}
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="offcanvas-body">
|
|
||||||
|
|
||||||
Hello
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// You must add this on a component based drawer
|
|
||||||
:host {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
|
||||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-view-annotation-drawer',
|
|
||||||
imports: [
|
|
||||||
TranslocoDirective
|
|
||||||
],
|
|
||||||
templateUrl: './view-annotation-drawer.component.html',
|
|
||||||
styleUrl: './view-annotation-drawer.component.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class ViewAnnotationDrawerComponent {
|
|
||||||
|
|
||||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.activeOffcanvas.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<ng-container *transloco="let t; prefix: 'view-bookmark-drawer'">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 class="offcanvas-title">
|
|
||||||
{{t('title')}}
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="offcanvas-body">
|
|
||||||
|
|
||||||
@let items = bookmarks();
|
|
||||||
@if (items) {
|
|
||||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1" [childHeight]="1">
|
|
||||||
|
|
||||||
<div class="card-container row g-0" #container>
|
|
||||||
@for(item of scroll.viewPortItems; let idx = $index; track item) {
|
|
||||||
<div>
|
|
||||||
<app-image [imageUrl]="imageService.getBookmarkedImage(item.chapterId, item.pageNum)" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</virtual-scroller>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// You must add this on a component based drawer
|
|
||||||
:host {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core';
|
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
|
||||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {ReaderService} from "../../../../_services/reader.service";
|
|
||||||
import {PageBookmark} from "../../../../_models/readers/page-bookmark";
|
|
||||||
import {ImageService} from "../../../../_services/image.service";
|
|
||||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
|
||||||
import {ImageComponent} from "../../../../shared/image/image.component";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-view-bookmarks-drawer',
|
|
||||||
imports: [
|
|
||||||
TranslocoDirective,
|
|
||||||
VirtualScrollerModule,
|
|
||||||
ImageComponent
|
|
||||||
],
|
|
||||||
templateUrl: './view-bookmark-drawer.component.html',
|
|
||||||
styleUrl: './view-bookmark-drawer.component.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class ViewBookmarkDrawerComponent {
|
|
||||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
private readonly readerService = inject(ReaderService);
|
|
||||||
protected readonly imageService = inject(ImageService);
|
|
||||||
|
|
||||||
chapterId = model<number>();
|
|
||||||
bookmarks = model<PageBookmark[]>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const id = this.chapterId();
|
|
||||||
if (!id) {
|
|
||||||
console.error('You must pass chapterId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.readerService.getBookmarks(id).subscribe(bookmarks => {
|
|
||||||
this.bookmarks.set(bookmarks);
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.activeOffcanvas.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<ng-container *transloco="let t; prefix: 'view-toc-drawer'">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 class="offcanvas-title">
|
|
||||||
{{t('title')}}
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="offcanvas-body">
|
|
||||||
|
|
||||||
<ul #subnav="ngbNav" ngbNav [(activeId)]="tocId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
|
||||||
<li [ngbNavItem]="TabID.TableOfContents">
|
|
||||||
<a ngbNavLink>{{t('toc-header')}}</a>
|
|
||||||
<ng-template ngbNavContent>
|
|
||||||
<app-table-of-contents [chapters]="chapters()" [chapterId]="chapterId()!" [pageNum]="pageNum"
|
|
||||||
[currentPageAnchor]="currentPageAnchor" (loadChapter)="loadChapterPage($event)"></app-table-of-contents>
|
|
||||||
</ng-template>
|
|
||||||
</li>
|
|
||||||
<li [ngbNavItem]="TabID.PersonalTableOfContents">
|
|
||||||
<a ngbNavLink>{{t('personal-header')}}</a>
|
|
||||||
<ng-template ngbNavContent>
|
|
||||||
<app-personal-table-of-contents [chapterId]="chapterId()!" [pageNum]="pageNum" (loadChapter)="loadChapterPart($event)"
|
|
||||||
[tocRefresh]="refreshPToC"></app-personal-table-of-contents>
|
|
||||||
</ng-template>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div [ngbNavOutlet]="subnav" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// You must add this on a component based drawer
|
|
||||||
:host {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
|
||||||
effect,
|
|
||||||
EventEmitter,
|
|
||||||
inject,
|
|
||||||
model
|
|
||||||
} from '@angular/core';
|
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
|
||||||
import {
|
|
||||||
NgbActiveOffcanvas,
|
|
||||||
NgbNav,
|
|
||||||
NgbNavContent,
|
|
||||||
NgbNavItem,
|
|
||||||
NgbNavLink,
|
|
||||||
NgbNavOutlet
|
|
||||||
} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {
|
|
||||||
PersonalTableOfContentsComponent,
|
|
||||||
PersonalToCEvent
|
|
||||||
} from "../../personal-table-of-contents/personal-table-of-contents.component";
|
|
||||||
import {TableOfContentsComponent} from "../../table-of-contents/table-of-contents.component";
|
|
||||||
import {BookChapterItem} from "../../../_models/book-chapter-item";
|
|
||||||
import {BookService} from "../../../_services/book.service";
|
|
||||||
|
|
||||||
|
|
||||||
enum TabID {
|
|
||||||
TableOfContents = 1,
|
|
||||||
PersonalTableOfContents = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface LoadPageEvent {
|
|
||||||
pageNumber: number;
|
|
||||||
part: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-view-toc-drawer',
|
|
||||||
imports: [
|
|
||||||
TranslocoDirective,
|
|
||||||
PersonalTableOfContentsComponent,
|
|
||||||
NgbNav,
|
|
||||||
NgbNavContent,
|
|
||||||
NgbNavLink,
|
|
||||||
TableOfContentsComponent,
|
|
||||||
NgbNavOutlet,
|
|
||||||
NgbNavItem
|
|
||||||
],
|
|
||||||
templateUrl: './view-toc-drawer.component.html',
|
|
||||||
styleUrl: './view-toc-drawer.component.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class ViewTocDrawerComponent {
|
|
||||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
private readonly bookService = inject(BookService);
|
|
||||||
|
|
||||||
chapterId = model<number>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sub Nav tab id
|
|
||||||
*/
|
|
||||||
tocId: TabID = TabID.TableOfContents;
|
|
||||||
/**
|
|
||||||
* The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors
|
|
||||||
*/
|
|
||||||
chapters = model<Array<BookChapterItem>>([]);
|
|
||||||
/**
|
|
||||||
* Current Page
|
|
||||||
*/
|
|
||||||
pageNum = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A anchors that map to the page number. When you click on one of these, we will load a given page up for the user.
|
|
||||||
*/
|
|
||||||
pageAnchors: {[n: string]: number } = {};
|
|
||||||
currentPageAnchor: string = '';
|
|
||||||
|
|
||||||
protected readonly TabID = TabID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to refresh the Personal PoC
|
|
||||||
*/
|
|
||||||
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
|
|
||||||
|
|
||||||
loadPage: EventEmitter<LoadPageEvent | null> = new EventEmitter<LoadPageEvent | null>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
const id = this.chapterId();
|
|
||||||
if (!id) {
|
|
||||||
console.error('You must pass chapterId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bookService.getBookChapters(id).subscribe(bookChapters => {
|
|
||||||
this.chapters.set(bookChapters);
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* From personal table of contents/bookmark
|
|
||||||
* @param event
|
|
||||||
*/
|
|
||||||
loadChapterPart(event: PersonalToCEvent) {
|
|
||||||
// this.setPageNum(event.pageNum);
|
|
||||||
// this.loadPage(event.scrollPart);
|
|
||||||
// TODO: Emit this event to let the main book reader handle
|
|
||||||
const evt = {pageNumber: event.pageNum, part:event.scrollPart} as LoadPageEvent;
|
|
||||||
this.loadPage.emit(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadChapterPage(event: {pageNum: number, part: string}) {
|
|
||||||
// this.setPageNum(event.pageNum);
|
|
||||||
// this.loadPage('id("' + event.part + '")');
|
|
||||||
// TODO: Emit this event to let the main book reader handle
|
|
||||||
const evt = {pageNumber: event.pageNum, part: `id("${event.part}")`} as LoadPageEvent;
|
|
||||||
this.loadPage.emit(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.activeOffcanvas.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +1,46 @@
|
||||||
<ng-container *transloco="let t; read: 'book-line-overlay'">
|
<ng-container *transloco="let t; read: 'book-line-overlay'">
|
||||||
@if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) {
|
<div class="overlay" *ngIf="selectedText.length > 0 || mode !== BookLineOverlayMode.None">
|
||||||
<div class="overlay">
|
|
||||||
|
|
||||||
<div class="row g-0 justify-content-between">
|
<div class="row g-0 justify-content-between">
|
||||||
@switch (mode) {
|
<ng-container [ngSwitch]="mode">
|
||||||
@case (BookLineOverlayMode.None) {
|
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button class="btn btn-icon btn-sm" (click)="copy()">
|
<button class="btn btn-icon btn-sm" (click)="copy()">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
<div>{{t('copy')}}</div>
|
<div>{{t('copy')}}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
<div class="col-auto">
|
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
|
||||||
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Annotate)">
|
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
||||||
<i class="fa-solid fa-highlighter" aria-hidden="true"></i>
|
<div>{{t('bookmark')}}</div>
|
||||||
<div>{{t('annotate')}}</div>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
|
|
||||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
|
||||||
<div>{{t('bookmark')}}</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-icon btn-sm" (click)="reset()">
|
|
||||||
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
|
|
||||||
<div>{{t('close')}}</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@case (BookLineOverlayMode.Annotate) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@case (BookLineOverlayMode.Bookmark) {
|
|
||||||
<form [formGroup]="bookmarkForm">
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('bookmark-label')"
|
|
||||||
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
|
||||||
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
|
|
||||||
@if (bookmarkForm.dirty || bookmarkForm.touched) {
|
|
||||||
<div id="bookmark-name-validations" class="invalid-feedback">
|
|
||||||
@if (bookmarkForm.get('name')?.errors?.required) {
|
|
||||||
<div role="status">
|
|
||||||
{{t('required-field')}}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-icon btn-sm" (click)="reset()">
|
||||||
|
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
|
||||||
|
<div>{{t('close')}}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">
|
||||||
|
<form [formGroup]="bookmarkForm">
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('book-label')"
|
||||||
|
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
||||||
|
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
|
||||||
|
<div id="bookmark-name-validations" class="invalid-feedback" *ngIf="bookmarkForm.dirty || bookmarkForm.touched">
|
||||||
|
<div *ngIf="bookmarkForm.get('name')?.errors?.required" role="status">
|
||||||
|
{{t('required-field')}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
}
|
</form>
|
||||||
}
|
</ng-container>
|
||||||
</div>
|
</ng-container>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy, ChangeDetectorRef,
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
ElementRef,
|
ElementRef, EventEmitter, HostListener,
|
||||||
EventEmitter,
|
|
||||||
HostListener,
|
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
OnInit,
|
OnInit, Output,
|
||||||
Output,
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import {CommonModule} from '@angular/common';
|
||||||
import {fromEvent, merge, of} from "rxjs";
|
import {fromEvent, merge, of} from "rxjs";
|
||||||
import {catchError} from "rxjs/operators";
|
import {catchError} from "rxjs/operators";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
|
@ -19,19 +16,15 @@ import {ReaderService} from "../../../_services/reader.service";
|
||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {KEY_CODES} from "../../../shared/_services/utility.service";
|
import {KEY_CODES} from "../../../shared/_services/utility.service";
|
||||||
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
|
|
||||||
import {CreateAnnotationRequest} from "../../_models/create-annotation-request";
|
|
||||||
import {HightlightColor} from "../../_models/annotation";
|
|
||||||
|
|
||||||
enum BookLineOverlayMode {
|
enum BookLineOverlayMode {
|
||||||
None = 0,
|
None = 0,
|
||||||
Annotate = 1,
|
Bookmark = 1
|
||||||
Bookmark = 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-book-line-overlay',
|
selector: 'app-book-line-overlay',
|
||||||
imports: [ReactiveFormsModule, TranslocoDirective],
|
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective],
|
||||||
templateUrl: './book-line-overlay.component.html',
|
templateUrl: './book-line-overlay.component.html',
|
||||||
styleUrls: ['./book-line-overlay.component.scss'],
|
styleUrls: ['./book-line-overlay.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|
@ -56,12 +49,9 @@ export class BookLineOverlayComponent implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly readerService = inject(ReaderService);
|
private readonly readerService = inject(ReaderService);
|
||||||
private readonly toastr = inject(ToastrService);
|
|
||||||
private readonly elementRef = inject(ElementRef);
|
|
||||||
private readonly epubMenuService = inject(EpubReaderMenuService);
|
|
||||||
|
|
||||||
protected readonly BookLineOverlayMode = BookLineOverlayMode;
|
|
||||||
|
|
||||||
|
get BookLineOverlayMode() { return BookLineOverlayMode; }
|
||||||
|
constructor(private elementRef: ElementRef, private toastr: ToastrService) {}
|
||||||
|
|
||||||
@HostListener('window:keydown', ['$event'])
|
@HostListener('window:keydown', ['$event'])
|
||||||
handleKeyPress(event: KeyboardEvent) {
|
handleKeyPress(event: KeyboardEvent) {
|
||||||
|
|
@ -130,32 +120,12 @@ export class BookLineOverlayComponent implements OnInit {
|
||||||
if (this.mode === BookLineOverlayMode.Bookmark) {
|
if (this.mode === BookLineOverlayMode.Bookmark) {
|
||||||
this.bookmarkForm.get('name')?.setValue(this.selectedText);
|
this.bookmarkForm.get('name')?.setValue(this.selectedText);
|
||||||
this.focusOnBookmarkInput();
|
this.focusOnBookmarkInput();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode === BookLineOverlayMode.Annotate) {
|
|
||||||
// TODO: Open annotation drawer
|
|
||||||
this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, this.xPath, this.selectedText
|
|
||||||
const createAnnotation = {
|
|
||||||
chapterId: this.chapterId,
|
|
||||||
libraryId: this.libraryId,
|
|
||||||
volumeId: this.volumeId,
|
|
||||||
comment: null,
|
|
||||||
selectedText: this.selectedText,
|
|
||||||
containsSpoiler: false,
|
|
||||||
pageNumber: this.pageNumber,
|
|
||||||
xpath: this.xPath,
|
|
||||||
endingXPath: this.xPath, // TODO: Figure this out
|
|
||||||
highlightCount: this.selectedText.length,
|
|
||||||
hightlightColor: HightlightColor.Blue
|
|
||||||
} as CreateAnnotationRequest;
|
|
||||||
this.epubMenuService.openCreateAnnotationDrawer(createAnnotation);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createPTOC() {
|
createPTOC() {
|
||||||
this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber,
|
this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber,
|
||||||
this.bookmarkForm.get('name')?.value, this.xPath, this.selectedText).pipe(catchError(err => {
|
this.bookmarkForm.get('name')?.value, this.xPath).pipe(catchError(err => {
|
||||||
this.focusOnBookmarkInput();
|
this.focusOnBookmarkInput();
|
||||||
return of();
|
return of();
|
||||||
})).subscribe(() => {
|
})).subscribe(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,161 +1,171 @@
|
||||||
<div class="container-flex {{darkMode() ? 'dark-mode' : ''}} reader-container {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
|
<div class="container-flex {{darkMode ? 'dark-mode' : ''}} reader-container {{ColumnLayout}} {{WritingStyleClass}}"
|
||||||
tabindex="0" #reader>
|
tabindex="0" #reader>
|
||||||
<ng-container *transloco="let t; prefix: 'book-reader'">
|
<ng-container *transloco="let t; read: 'book-reader'">
|
||||||
<div class="fixed-top" #stickyTop>
|
<div class="fixed-top" #stickyTop>
|
||||||
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-header')}}</a>
|
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-header')}}</a>
|
||||||
<ng-container [ngTemplateOutlet]="topActionBar" [ngTemplateOutletContext]="{isTop: true}"></ng-container>
|
<ng-container [ngTemplateOutlet]="actionBar" [ngTemplateOutletContext]="{isTop: true}"></ng-container>
|
||||||
@if (page !== undefined) {
|
<app-book-line-overlay [parent]="bookContainerElemRef" *ngIf="page !== undefined"
|
||||||
<app-book-line-overlay [parent]="bookContainerElemRef"
|
[libraryId]="libraryId"
|
||||||
[libraryId]="libraryId"
|
[volumeId]="volumeId"
|
||||||
[volumeId]="volumeId"
|
[chapterId]="chapterId"
|
||||||
[chapterId]="chapterId"
|
[seriesId]="seriesId"
|
||||||
[seriesId]="seriesId"
|
[pageNumber]="pageNum"
|
||||||
[pageNumber]="pageNum()"
|
(isOpen)="updateLineOverlayOpen($event)"
|
||||||
(isOpen)="updateLineOverlayOpen($event)"
|
(refreshToC)="refreshPersonalToC()">
|
||||||
(refreshToC)="refreshPersonalToC()">
|
</app-book-line-overlay>
|
||||||
</app-book-line-overlay>
|
<app-drawer #commentDrawer="drawer" [(isOpen)]="drawerOpen" [options]="{topOffset: topOffset}">
|
||||||
}
|
<div header>
|
||||||
|
<h5 class="mb-0">{{t('title')}}</h5>
|
||||||
|
<span style="font-size: 14px; color: var(--primary-color)" tabindex="0" role="button" (click)="closeReader()">{{t('close-reader')}}</span>
|
||||||
|
</div>
|
||||||
|
<div subheader>
|
||||||
|
<div class="pagination-cont">
|
||||||
|
<!-- Column mode needs virtual pages -->
|
||||||
|
@if (layoutMode !== BookPageLayoutMode.Default) {
|
||||||
|
@let vp = getVirtualPage();
|
||||||
|
<div class="virt-pagination-cont">
|
||||||
|
<div class="g-0 text-center">
|
||||||
|
{{t('page-label')}}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between text-center row g-0">
|
||||||
|
<button class="btn btn-small btn-icon col-1" (click)="prevPage()" [title]="t('prev-page')" [disabled]="vp[0] === 1">
|
||||||
|
<i class="fa-solid fa-caret-left" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="col-1">{{vp[0]}}</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<ngb-progressbar type="primary" height="5px" [value]="vp[0]" [max]="vp[1]"></ngb-progressbar>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 btn-icon">{{vp[1]}}</div>
|
||||||
|
<button class="btn btn-small btn-icon col-1" (click)="nextPage()" [title]="t('next-page')"><i class="fa-solid fa-caret-right" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="g-0 text-center">
|
||||||
|
{{t('pagination-header')}}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between text-center row g-0">
|
||||||
|
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" [title]="t('prev-chapter')"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
||||||
|
<div class="col-1" (click)="goToPage(0)">{{pageNum}}</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<ngb-progressbar class="clickable" [title]="t('go-to-page')" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 btn-icon" (click)="goToPage(maxPages - 1)" [title]="t('go-to-last-page')">{{maxPages - 1}}</div>
|
||||||
|
<button class="btn btn-small btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter()" [title]="t('next-chapter')"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div body class="drawer-body">
|
||||||
|
<nav role="navigation">
|
||||||
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||||
|
<li [ngbNavItem]="TabID.Settings">
|
||||||
|
<a ngbNavLink>{{t('settings-header')}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<app-reader-settings
|
||||||
|
[seriesId]="seriesId"
|
||||||
|
[readingProfile]="readingProfile"
|
||||||
|
(colorThemeUpdate)="updateColorTheme($event)"
|
||||||
|
(styleUpdate)="updateReaderStyles($event)"
|
||||||
|
(clickToPaginateChanged)="showPaginationOverlay($event)"
|
||||||
|
(fullscreen)="toggleFullscreen()"
|
||||||
|
(bookReaderWritingStyle)="updateWritingStyle($event)"
|
||||||
|
(layoutModeUpdate)="updateLayoutMode($event)"
|
||||||
|
(readingDirection)="updateReadingDirection($event)"
|
||||||
|
(immersiveMode)="updateImmersiveMode($event)"
|
||||||
|
></app-reader-settings>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li [ngbNavItem]="TabID.TableOfContents">
|
||||||
|
<a ngbNavLink>{{t('table-of-contents-header')}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<ul #subnav="ngbNav" ngbNav [(activeId)]="tocId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||||
|
<li [ngbNavItem]="TabID.TableOfContents">
|
||||||
|
<a ngbNavLink>{{t('toc-header')}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<app-table-of-contents [chapters]="chapters" [chapterId]="chapterId" [pageNum]="pageNum"
|
||||||
|
[currentPageAnchor]="currentPageAnchor" (loadChapter)="loadChapterPage($event)"></app-table-of-contents>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
<li [ngbNavItem]="TabID.PersonalTableOfContents">
|
||||||
|
<a ngbNavLink>{{t('bookmarks-header')}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<app-personal-table-of-contents [chapterId]="chapterId" [pageNum]="pageNum" (loadChapter)="loadChapterPart($event)"
|
||||||
|
[tocRefresh]="refreshPToC"></app-personal-table-of-contents>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div [ngbNavOutlet]="subnav" class="mt-3"></div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</app-drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div #readingSection class="reading-section {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
|
<div #readingSection class="reading-section {{ColumnLayout}} {{WritingStyleClass}}" [ngStyle]="{'width': PageWidthForPagination}"
|
||||||
[ngStyle]="{'width': PageWidthForPagination}"
|
[ngClass]="{'immersive' : immersiveMode || !actionBarVisible}" [@isLoading]="isLoading" (click)="handleReaderClick($event)">
|
||||||
[ngClass]="{'immersive' : immersiveMode() || !actionBarVisible}" [@isLoading]="isLoading" (click)="handleReaderClick($event)">
|
|
||||||
|
|
||||||
@if (clickToPaginate() && !hidePagination) {
|
<ng-container *ngIf="clickToPaginate && !hidePagination">
|
||||||
<div class="left {{clickOverlayClass('left')}} no-observe"
|
<div class="left {{clickOverlayClass('left')}} no-observe"
|
||||||
(click)="movePage(readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||||
[ngClass]="{'immersive' : immersiveMode()}"
|
[ngClass]="{'immersive' : immersiveMode}"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
[ngStyle]="{height: PageHeightForPagination}"></div>
|
[ngStyle]="{height: PageHeightForPagination}"></div>
|
||||||
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
|
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
|
||||||
(click)="movePage(readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
||||||
[ngClass]="{'immersive' : immersiveMode()}"
|
[ngClass]="{'immersive' : immersiveMode}"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
[ngStyle]="{height: PageHeightForPagination}"></div>
|
[ngStyle]="{height: PageHeightForPagination}"></div>
|
||||||
}
|
</ng-container>
|
||||||
|
<div #bookContainer class="book-container {{WritingStyleClass}}"
|
||||||
|
[ngClass]="{'immersive' : immersiveMode}"
|
||||||
|
(mousedown)="mouseDown($event)" >
|
||||||
|
|
||||||
<div #bookContainer class="book-container {{writingStyle() | writingStyleClass}}"
|
<div #readingHtml class="book-content {{ColumnLayout}} {{WritingStyleClass}}"
|
||||||
[ngClass]="{'immersive' : immersiveMode()}"
|
[ngStyle]="{'max-height': ColumnHeight, 'max-width': VerticalBookContentWidth, 'width': VerticalBookContentWidth, 'column-width': ColumnWidth}"
|
||||||
(mousedown)="mouseDown($event)" >
|
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"
|
||||||
|
[innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div>
|
||||||
@if (page !== undefined) {
|
<div *ngIf="page !== undefined && (scrollbarNeeded || layoutMode !== BookPageLayoutMode.Default) && !(writingStyle === WritingStyle.Vertical && layoutMode === BookPageLayoutMode.Default)"
|
||||||
<div #readingHtml class="book-content {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
|
(click)="$event.stopPropagation();"
|
||||||
[ngStyle]="{'max-height': columnHeight(), 'max-width': verticalBookContentWidth(), 'width': verticalBookContentWidth(), 'column-width': columnWidth()}"
|
[ngClass]="{'bottom-bar': layoutMode !== BookPageLayoutMode.Default}">
|
||||||
[ngClass]="{'immersive': immersiveMode() && actionBarVisible}"
|
<ng-container [ngTemplateOutlet]="actionBar" [ngTemplateOutletContext]="{isTop: false}"></ng-container>
|
||||||
[innerHtml]="page" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div>
|
</div>
|
||||||
|
|
||||||
@if ((scrollbarNeeded || layoutMode() !== BookPageLayoutMode.Default) && !(writingStyle() === WritingStyle.Vertical && layoutMode() === BookPageLayoutMode.Default)) {
|
|
||||||
<div (click)="$event.stopPropagation();"
|
|
||||||
[ngClass]="{'bottom-bar': layoutMode() !== BookPageLayoutMode.Default}">
|
|
||||||
<ng-container [ngTemplateOutlet]="actionBar" [ngTemplateOutletContext]="{isTop: false}"></ng-container>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ng-template #topActionBar>
|
|
||||||
@if (!immersiveMode() || epubMenuService.isDrawerOpen() || actionBarVisible) {
|
|
||||||
<div class="action-bar d-flex align-items-center px-2">
|
|
||||||
<!-- Left: Drawer toggle -->
|
|
||||||
<button class="btn btn-secondary me-2" (click)="toggleDrawer()">
|
|
||||||
<i class="fa fa-bars" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Center: Book Title -->
|
|
||||||
<div class="flex-grow-1 text-center d-none d-md-block">
|
|
||||||
@if (isLoading) {
|
|
||||||
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
|
|
||||||
<span class="visually-hidden">{{ t('loading-book') }}</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
@if (incognitoMode) {
|
|
||||||
<span (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-mode-alt')">
|
|
||||||
(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{ t('incognito-mode-label') }}</span>)
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<span class="ms-1 book-title-text" [ngbTooltip]="bookTitle">{{ bookTitle }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Buttons -->
|
|
||||||
<div class="d-flex align-items-center ms-auto">
|
|
||||||
@if (!this.adhocPageHistory.isEmpty()) {
|
|
||||||
<button class="btn btn-outline-secondary btn-icon me-1" (click)="goBack()" [title]="t('go-back')">
|
|
||||||
<i class="fa fa-reply" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button class="btn btn-secondary btn-icon me-1" (click)="closeReader()">
|
|
||||||
<i class="fa fa-times-circle" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-icon me-1" (click)="epubMenuService.openViewBookmarksDrawer(this.chapterId)">
|
|
||||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-icon me-1" (click)="epubMenuService.openViewAnnotationsDrawer(this.chapterId)">
|
|
||||||
<i class="fa-solid fa-highlighter" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-icon" (click)="viewToCDrawer()">
|
|
||||||
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<ng-template #actionBar let-isTop>
|
<ng-template #actionBar let-isTop>
|
||||||
@if (!immersiveMode() || epubMenuService.isDrawerOpen() || actionBarVisible) {
|
<div class="action-bar row g-0 justify-content-between" *ngIf="!immersiveMode || drawerOpen || actionBarVisible">
|
||||||
<div class="action-bar row g-0 justify-content-between">
|
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
||||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
(click)="!isTop && movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||||
(click)="!isTop && movePage(readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
[disabled]="readingDirection === ReadingDirection.LeftToRight ? IsPrevDisabled : IsNextDisabled"
|
||||||
[disabled]="readingDirection() === ReadingDirection.LeftToRight ? IsPrevDisabled : IsNextDisabled"
|
title="{{readingDirection === ReadingDirection.LeftToRight ? t('previous') : t('next')}} Page">
|
||||||
title="{{readingDirection() === ReadingDirection.LeftToRight ? t('previous') : t('next')}} Page">
|
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsPrevChapter : IsNextChapter) ? 'fa-angle-double-left' : 'fa-angle-left'}} {{readingDirection === ReadingDirection.RightToLeft ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
||||||
<i class="fa {{(readingDirection() === ReadingDirection.LeftToRight ? IsPrevChapter : IsNextChapter) ? 'fa-angle-double-left' : 'fa-angle-left'}} {{readingDirection() === ReadingDirection.RightToLeft ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
</button>
|
||||||
</button>
|
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" [title]="t('go-back')">
|
||||||
|
<i class="fa fa-reply" aria-hidden="true"></i>
|
||||||
@if (!this.adhocPageHistory.isEmpty()) {
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" [title]="t('go-back')">
|
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()">
|
||||||
<i class="fa fa-reply" aria-hidden="true"></i>
|
<i class="fa fa-bars" aria-hidden="true"></i></button>
|
||||||
</button>
|
<div class="book-title col-2 d-none d-sm-block">
|
||||||
|
@if(isLoading) {
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
|
||||||
|
<span class="visually-hidden">{{t('loading-book')}}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-mode-alt')">
|
||||||
|
(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{t('incognito-mode-label')}}</span>)</span>
|
||||||
|
<span class="book-title-text ms-1" [ngbTooltip]="bookTitle">{{bookTitle}}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
<div class="book-title col-3 d-none d-sm-block">
|
|
||||||
@if(isLoading) {
|
|
||||||
<!--Just render a blank div here-->
|
|
||||||
} @else {
|
|
||||||
|
|
||||||
<!--BookFusion: Page X 10 pages left, 30% -->
|
|
||||||
|
|
||||||
<span class="me-1">
|
|
||||||
{{t('page-num-label', {page: pageNum()})}} / {{maxPages}}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span> {{t('completion-label', {percent: (pageNum() / maxPages) | percent})}}</span>
|
|
||||||
|
|
||||||
|
|
||||||
@let timeLeft = readingTimeLeftResource.value();
|
|
||||||
@if (timeLeft) {
|
|
||||||
,
|
|
||||||
<span class="time-left">
|
|
||||||
<i class="fa-solid fa-clock" aria-hidden="true"></i>
|
|
||||||
{{timeLeft! | readTimeLeft:true }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
|
||||||
[disabled]="readingDirection() === ReadingDirection.LeftToRight ? IsNextDisabled : IsPrevDisabled"
|
|
||||||
(click)="!isTop && movePage(readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
|
||||||
title="{{readingDirection() === ReadingDirection.LeftToRight ? t('next') : t('previous')}} Page">
|
|
||||||
<i class="fa {{(readingDirection() === ReadingDirection.LeftToRight ? IsNextChapter : IsPrevChapter) ? 'fa-angle-double-right' : 'fa-angle-right'}} {{readingDirection() === ReadingDirection.LeftToRight ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
|
||||||
|
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
||||||
|
[disabled]="readingDirection === ReadingDirection.LeftToRight ? IsNextDisabled : IsPrevDisabled"
|
||||||
|
(click)="!isTop && movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
||||||
|
title="{{readingDirection === ReadingDirection.LeftToRight ? t('next') : t('previous')}} Page">
|
||||||
|
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsNextChapter : IsPrevChapter) ? 'fa-angle-double-right' : 'fa-angle-right'}} {{readingDirection === ReadingDirection.LeftToRight ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -355,10 +355,6 @@ $pagination-opacity: 0;
|
||||||
//$pagination-color: red;
|
//$pagination-color: red;
|
||||||
//$pagination-opacity: 0.7;
|
//$pagination-opacity: 0.7;
|
||||||
|
|
||||||
.kavita-scale-width::after {
|
|
||||||
content: ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -371,7 +367,6 @@ $pagination-opacity: 0;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.immersive {
|
&.immersive {
|
||||||
top: 0px;
|
top: 0px;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,36 +1,32 @@
|
||||||
<ng-container *transloco="let t; prefix: 'personal-table-of-contents'">
|
<ng-container *transloco="let t; read: 'personal-table-of-contents'">
|
||||||
<div class="table-of-contents">
|
<div class="table-of-contents">
|
||||||
@let bookmarks = ptocBookmarks();
|
@if (Pages.length === 0) {
|
||||||
|
|
||||||
@if(bookmarks.length >= ShowFilterAfterItems) {
|
|
||||||
<form [formGroup]="formGroup">
|
|
||||||
<div class="row g-0 mb-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for(bookmark of bookmarks | filter: filterList; track bookmark.pageNumber + bookmark.title) {
|
|
||||||
<div class="mb-2">
|
|
||||||
<app-text-bookmark-item [bookmark]="bookmark"
|
|
||||||
(loadBookmark)="loadChapterPage($event.pageNumber, $event.bookScrollId)"
|
|
||||||
(removeBookmark)="removeBookmark($event)" />
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div>
|
<div>
|
||||||
@if (formGroup.get('filter')?.value) {
|
<em>{{t('no-data')}}</em>
|
||||||
<em>{{t('no-match')}}</em>
|
|
||||||
} @else {
|
|
||||||
<em>{{t('no-data')}}</em>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<ul>
|
||||||
|
@for (page of Pages; track page) {
|
||||||
|
<li>
|
||||||
|
<span (click)="loadChapterPage(page, '')">{{t('page', {value: page})}}</span>
|
||||||
|
<ul class="chapter-title">
|
||||||
|
@for(bookmark of bookmarks[page]; track bookmark) {
|
||||||
|
<li class="ellipsis"
|
||||||
|
[ngbTooltip]="bookmark.title"
|
||||||
|
placement="right"
|
||||||
|
(click)="loadChapterPage(bookmark.pageNumber, bookmark.bookScrollId); $event.stopPropagation();">
|
||||||
|
{{bookmark.title}}
|
||||||
|
<button class="btn btn-icon ms-1" (click)="removeBookmark(bookmark); $event.stopPropagation();">
|
||||||
|
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">{{t('delete', {bookmarkName: bookmark.title})}}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
ChangeDetectorRef,
|
||||||
DestroyRef,
|
Component, DestroyRef, EventEmitter,
|
||||||
EventEmitter,
|
Inject,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
model,
|
|
||||||
OnInit,
|
OnInit,
|
||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import {DOCUMENT} from '@angular/common';
|
||||||
import {ReaderService} from "../../../_services/reader.service";
|
import {ReaderService} from "../../../_services/reader.service";
|
||||||
import {PersonalToC} from "../../../_models/readers/personal-toc";
|
import {PersonalToC} from "../../../_models/readers/personal-toc";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {TextBookmarkItemComponent} from "../text-bookmark-item/text-bookmark-item.component";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {ConfirmService} from "../../../shared/confirm.service";
|
|
||||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
|
||||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
|
|
||||||
|
|
||||||
export interface PersonalToCEvent {
|
export interface PersonalToCEvent {
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
|
|
@ -24,32 +21,31 @@ export interface PersonalToCEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-personal-table-of-contents',
|
selector: 'app-personal-table-of-contents',
|
||||||
imports: [TranslocoDirective, TextBookmarkItemComponent, FilterPipe, FormsModule, ReactiveFormsModule],
|
imports: [NgbTooltip, TranslocoDirective],
|
||||||
templateUrl: './personal-table-of-contents.component.html',
|
templateUrl: './personal-table-of-contents.component.html',
|
||||||
styleUrls: ['./personal-table-of-contents.component.scss'],
|
styleUrls: ['./personal-table-of-contents.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class PersonalTableOfContentsComponent implements OnInit {
|
export class PersonalTableOfContentsComponent implements OnInit {
|
||||||
|
|
||||||
private readonly readerService = inject(ReaderService);
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly confirmService = inject(ConfirmService);
|
|
||||||
|
|
||||||
protected readonly ShowFilterAfterItems = 10;
|
|
||||||
|
|
||||||
@Input({required: true}) chapterId!: number;
|
@Input({required: true}) chapterId!: number;
|
||||||
@Input({required: true}) pageNum: number = 0;
|
@Input({required: true}) pageNum: number = 0;
|
||||||
@Input({required: true}) tocRefresh!: EventEmitter<void>;
|
@Input({required: true}) tocRefresh!: EventEmitter<void>;
|
||||||
@Output() loadChapter: EventEmitter<PersonalToCEvent> = new EventEmitter();
|
@Output() loadChapter: EventEmitter<PersonalToCEvent> = new EventEmitter();
|
||||||
|
|
||||||
|
private readonly readerService = inject(ReaderService);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
|
||||||
|
bookmarks: {[key: number]: Array<PersonalToC>} = [];
|
||||||
|
|
||||||
ptocBookmarks = model<PersonalToC[]>([]);
|
get Pages() {
|
||||||
formGroup = new FormGroup({
|
return Object.keys(this.bookmarks).map(p => parseInt(p, 10));
|
||||||
filter: new FormControl('', [])
|
}
|
||||||
});
|
|
||||||
|
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
|
|
@ -61,7 +57,13 @@ export class PersonalTableOfContentsComponent implements OnInit {
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
this.readerService.getPersonalToC(this.chapterId).subscribe(res => {
|
this.readerService.getPersonalToC(this.chapterId).subscribe(res => {
|
||||||
this.ptocBookmarks.set(res);
|
res.forEach(t => {
|
||||||
|
if (!this.bookmarks.hasOwnProperty(t.pageNumber)) {
|
||||||
|
this.bookmarks[t.pageNumber] = [];
|
||||||
|
}
|
||||||
|
this.bookmarks[t.pageNumber].push(t);
|
||||||
|
})
|
||||||
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,18 +71,15 @@ export class PersonalTableOfContentsComponent implements OnInit {
|
||||||
this.loadChapter.emit({pageNum, scrollPart});
|
this.loadChapter.emit({pageNum, scrollPart});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeBookmark(bookmark: PersonalToC) {
|
removeBookmark(bookmark: PersonalToC) {
|
||||||
|
|
||||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-bookmark'))) return;
|
|
||||||
|
|
||||||
this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => {
|
this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => {
|
||||||
this.ptocBookmarks.set(this.ptocBookmarks().filter(t => t.title !== bookmark.title));
|
this.bookmarks[bookmark.pageNumber] = this.bookmarks[bookmark.pageNumber].filter(t => t.title != bookmark.title);
|
||||||
|
|
||||||
|
if (this.bookmarks[bookmark.pageNumber].length === 0) {
|
||||||
|
delete this.bookmarks[bookmark.pageNumber];
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
filterList = (listItem: PersonalToC) => {
|
|
||||||
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
|
|
||||||
return listItem.title.toLowerCase().indexOf(query) >= 0 || listItem.pageNumber.toString().indexOf(query) >= 0 || (listItem.chapterTitle ?? '').toLowerCase().indexOf(query) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@if (readingProfile !== null) {
|
@if (readingProfile !== null) {
|
||||||
<ng-container *transloco="let t; read: 'reader-settings'">
|
<ng-container *transloco="let t; read: 'reader-settings'">
|
||||||
|
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
|
||||||
<form [formGroup]="settingsForm">
|
<form [formGroup]="settingsForm">
|
||||||
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
|
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
|
||||||
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
|
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
|
||||||
|
|
@ -16,9 +17,7 @@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
|
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
|
||||||
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
|
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
|
||||||
@for(opt of fontOptions; track opt) {
|
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
|
||||||
<option [value]="opt">{{opt | titlecase}}</option>
|
|
||||||
}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,9 +25,7 @@
|
||||||
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
|
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
|
||||||
<span class="col-6 float-end" style="display: inline-flex;">
|
<span class="col-6 float-end" style="display: inline-flex;">
|
||||||
<i class="fa-solid fa-font" style="font-size: 12px;"></i>
|
<i class="fa-solid fa-font" style="font-size: 12px;"></i>
|
||||||
<input type="range" class="form-range ms-2 me-2" id="fontsize"
|
<input type="range" class="form-range ms-2 me-2" id="fontsize" min="50" max="300" step="10" formControlName="bookReaderFontSize" [ngbTooltip]="settingsForm.get('bookReaderFontSize')?.value + '%'">
|
||||||
min="50" max="300" step="10" formControlName="bookReaderFontSize"
|
|
||||||
[ngbTooltip]="pageStyles()['font-size']">
|
|
||||||
<i class="fa-solid fa-font" style="font-size: 24px;"></i>
|
<i class="fa-solid fa-font" style="font-size: 24px;"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,9 +34,7 @@
|
||||||
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
|
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
|
||||||
<span class="col-6 float-end" style="display: inline-flex;">
|
<span class="col-6 float-end" style="display: inline-flex;">
|
||||||
{{t('line-spacing-min-label')}}
|
{{t('line-spacing-min-label')}}
|
||||||
<input type="range" class="form-range ms-2 me-2" id="linespacing"
|
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
|
||||||
min="100" max="200" step="10" formControlName="bookReaderLineSpacing"
|
|
||||||
[ngbTooltip]="pageStyles()['line-height']">
|
|
||||||
{{t('line-spacing-max-label')}}
|
{{t('line-spacing-max-label')}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,18 +70,18 @@
|
||||||
<ng-template>
|
<ng-template>
|
||||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
|
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
|
||||||
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel() === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
|
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
|
||||||
<i class="fa {{readingDirectionModel() === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
|
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
|
||||||
<span class="phone-hidden"> {{readingDirectionModel() === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
|
<span class="phone-hidden"> {{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
|
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
|
||||||
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
|
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
|
||||||
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
|
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
|
||||||
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
|
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
|
||||||
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel() === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
|
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
|
||||||
<i class="fa {{writingStyleModel() === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
|
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
|
||||||
<span class="phone-hidden"> {{writingStyleModel() === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
|
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,10 +116,8 @@
|
||||||
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
|
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
|
||||||
</span>
|
</span>
|
||||||
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
||||||
<i class="fa {{isFullscreen() ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen() ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
<i class="fa {{isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
||||||
@if (activeTheme()?.isDarkTheme) {
|
<span *ngIf="activeTheme?.isDarkTheme"> {{isFullscreen ? t('exit') : t('enter')}}</span>
|
||||||
<span class="ms-1">{{isFullscreen() ? t('exit') : t('enter')}}</span>
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,28 +156,27 @@
|
||||||
<div ngbAccordionBody>
|
<div ngbAccordionBody>
|
||||||
<ng-template>
|
<ng-template>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
@for(theme of themes; track theme.name) {
|
<ng-container *ngFor="let theme of themes">
|
||||||
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme()?.name === theme.name}">
|
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
|
||||||
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
|
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
|
||||||
{{t(theme.translationKey)}}
|
{{t(theme.translationKey)}}
|
||||||
</button>
|
</button>
|
||||||
}
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@let currentRP = currentReadingProfile();
|
|
||||||
<div class="row g-0 mt-2">
|
<div class="row g-0 mt-2">
|
||||||
<button class="btn btn-primary col-12 mb-2"
|
<button class="btn btn-primary col-12 mb-2"
|
||||||
[disabled]="!currentRP || currentRP.kind !== ReadingProfileKind.Implicit || !hasParentProfile()"
|
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit || !parentReadingProfile"
|
||||||
(click)="updateParentPref()">
|
(click)="updateParentPref()">
|
||||||
{{ t('update-parent', {name: parentReadingProfile()?.name || t('loading')}) }}
|
{{ t('update-parent', {name: parentReadingProfile?.name || t('loading')}) }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary col-12 mb-2"
|
<button class="btn btn-primary col-12 mb-2"
|
||||||
[ngbTooltip]="t('create-new-tooltip')"
|
[ngbTooltip]="t('create-new-tooltip')"
|
||||||
[disabled]="!canPromoteProfile()"
|
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit"
|
||||||
(click)="createNewProfileFromImplicit()">
|
(click)="createNewProfileFromImplicit()">
|
||||||
{{ t('create-new') }}
|
{{ t('create-new') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,32 @@
|
||||||
import {NgClass, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
import {DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
import {
|
||||||
import {FormGroup, ReactiveFormsModule} from '@angular/forms';
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
EventEmitter,
|
||||||
|
inject,
|
||||||
|
Inject,
|
||||||
|
Input,
|
||||||
|
OnInit,
|
||||||
|
Output
|
||||||
|
} from '@angular/core';
|
||||||
|
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||||
|
import {skip, take} from 'rxjs';
|
||||||
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
|
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
|
||||||
import {BookTheme} from 'src/app/_models/preferences/book-theme';
|
import {BookTheme} from 'src/app/_models/preferences/book-theme';
|
||||||
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
|
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
|
||||||
import {WritingStyle} from 'src/app/_models/preferences/writing-style';
|
import {WritingStyle} from 'src/app/_models/preferences/writing-style';
|
||||||
import {ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
import {ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
||||||
import {FontFamily} from '../../_services/book.service';
|
import {User} from 'src/app/_models/user';
|
||||||
|
import {AccountService} from 'src/app/_services/account.service';
|
||||||
|
import {ThemeService} from 'src/app/_services/theme.service';
|
||||||
|
import {BookService, FontFamily} from '../../_services/book.service';
|
||||||
import {BookBlackTheme} from '../../_models/book-black-theme';
|
import {BookBlackTheme} from '../../_models/book-black-theme';
|
||||||
import {BookDarkTheme} from '../../_models/book-dark-theme';
|
import {BookDarkTheme} from '../../_models/book-dark-theme';
|
||||||
import {BookWhiteTheme} from '../../_models/book-white-theme';
|
import {BookWhiteTheme} from '../../_models/book-white-theme';
|
||||||
import {BookPaperTheme} from '../../_models/book-paper-theme';
|
import {BookPaperTheme} from '../../_models/book-paper-theme';
|
||||||
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {
|
import {
|
||||||
NgbAccordionBody,
|
NgbAccordionBody,
|
||||||
NgbAccordionButton,
|
NgbAccordionButton,
|
||||||
|
|
@ -20,10 +36,11 @@ import {
|
||||||
NgbAccordionItem,
|
NgbAccordionItem,
|
||||||
NgbTooltip
|
NgbTooltip
|
||||||
} from '@ng-bootstrap/ng-bootstrap';
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||||
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
|
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
|
||||||
import {EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service";
|
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
||||||
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used for book reader. Do not use for other components
|
* Used for book reader. Do not use for other components
|
||||||
|
|
@ -79,95 +96,371 @@ export const bookColorThemes = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mobileBreakpointMarginOverride = 700;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-reader-settings',
|
selector: 'app-reader-settings',
|
||||||
templateUrl: './reader-settings.component.html',
|
templateUrl: './reader-settings.component.html',
|
||||||
styleUrls: ['./reader-settings.component.scss'],
|
styleUrls: ['./reader-settings.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton,
|
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton,
|
||||||
NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, NgClass, NgStyle,
|
NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle,
|
||||||
TitleCasePipe, TranslocoDirective]
|
TitleCasePipe, TranslocoDirective]
|
||||||
})
|
})
|
||||||
export class ReaderSettingsComponent implements OnInit {
|
export class ReaderSettingsComponent implements OnInit {
|
||||||
|
|
||||||
private readonly readerSettingsService = inject(EpubReaderSettingsService);
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
private readonly readingProfileService = inject(ReadingProfileService);
|
|
||||||
|
|
||||||
@Input({required:true}) seriesId!: number;
|
@Input({required:true}) seriesId!: number;
|
||||||
@Input({required:true}) readingProfile!: ReadingProfile;
|
@Input({required:true}) readingProfile!: ReadingProfile;
|
||||||
|
/**
|
||||||
|
* Outputs when clickToPaginate is changed
|
||||||
|
*/
|
||||||
|
@Output() clickToPaginateChanged: EventEmitter<boolean> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when a style is updated and the reader needs to render it
|
||||||
|
*/
|
||||||
|
@Output() styleUpdate: EventEmitter<PageStyle> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when a theme/dark mode is updated
|
||||||
|
*/
|
||||||
|
@Output() colorThemeUpdate: EventEmitter<BookTheme> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when a layout mode is updated
|
||||||
|
*/
|
||||||
|
@Output() layoutModeUpdate: EventEmitter<BookPageLayoutMode> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when fullscreen is toggled
|
||||||
|
*/
|
||||||
|
@Output() fullscreen: EventEmitter<void> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when reading direction is changed
|
||||||
|
*/
|
||||||
|
@Output() readingDirection: EventEmitter<ReadingDirection> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when reading mode is changed
|
||||||
|
*/
|
||||||
|
@Output() bookReaderWritingStyle: EventEmitter<WritingStyle> = new EventEmitter();
|
||||||
|
/**
|
||||||
|
* Outputs when immersive mode is changed
|
||||||
|
*/
|
||||||
|
@Output() immersiveMode: EventEmitter<boolean> = new EventEmitter();
|
||||||
|
|
||||||
|
user!: User;
|
||||||
/**
|
/**
|
||||||
* List of all font families user can select from
|
* List of all font families user can select from
|
||||||
*/
|
*/
|
||||||
fontOptions: Array<string> = [];
|
fontOptions: Array<string> = [];
|
||||||
fontFamilies: Array<FontFamily> = [];
|
fontFamilies: Array<FontFamily> = [];
|
||||||
|
/**
|
||||||
|
* Internal property used to capture all the different css properties to render on all elements
|
||||||
|
*/
|
||||||
|
pageStyles!: PageStyle;
|
||||||
|
|
||||||
|
readingDirectionModel: ReadingDirection = ReadingDirection.LeftToRight;
|
||||||
|
|
||||||
|
writingStyleModel: WritingStyle = WritingStyle.Horizontal;
|
||||||
|
|
||||||
|
|
||||||
|
activeTheme: BookTheme | undefined;
|
||||||
|
|
||||||
|
isFullscreen: boolean = false;
|
||||||
|
|
||||||
settingsForm: FormGroup = new FormGroup({});
|
settingsForm: FormGroup = new FormGroup({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reading profile itself, unless readingProfile is implicit
|
||||||
|
*/
|
||||||
|
parentReadingProfile: ReadingProfile | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System provided themes
|
* System provided themes
|
||||||
*/
|
*/
|
||||||
themes: Array<BookTheme> = this.readerSettingsService.getThemes();
|
themes: Array<BookTheme> = bookColorThemes;
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
protected readonly pageStyles = this.readerSettingsService.pageStyles;
|
|
||||||
protected readonly readingDirectionModel = this.readerSettingsService.readingDirection;
|
|
||||||
protected readonly writingStyleModel = this.readerSettingsService.writingStyle;
|
|
||||||
protected readonly activeTheme = this.readerSettingsService.activeTheme;
|
|
||||||
protected readonly layoutMode = this.readerSettingsService.layoutMode;
|
|
||||||
protected readonly immersiveMode = this.readerSettingsService.immersiveMode;
|
|
||||||
protected readonly clickToPaginate = this.readerSettingsService.clickToPaginate;
|
|
||||||
protected readonly isFullscreen = this.readerSettingsService.isFullscreen;
|
|
||||||
protected readonly canPromoteProfile = this.readerSettingsService.canPromoteProfile;
|
|
||||||
protected readonly hasParentProfile = this.readerSettingsService.hasParentProfile;
|
|
||||||
protected readonly parentReadingProfile = this.readerSettingsService.parentReadingProfile;
|
|
||||||
protected readonly currentReadingProfile = this.readerSettingsService.currentReadingProfile;
|
|
||||||
|
|
||||||
|
|
||||||
async ngOnInit() {
|
get BookPageLayoutMode(): typeof BookPageLayoutMode {
|
||||||
// Initialize the service if not already done
|
return BookPageLayoutMode;
|
||||||
if (!this.readerSettingsService.getCurrentReadingProfile()) {
|
}
|
||||||
await this.readerSettingsService.initialize(this.seriesId, this.readingProfile);
|
|
||||||
|
get ReadingDirection() {
|
||||||
|
return ReadingDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
get WritingStyle() {
|
||||||
|
return WritingStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private bookService: BookService, private accountService: AccountService,
|
||||||
|
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService,
|
||||||
|
private readonly cdRef: ChangeDetectorRef, private readingProfileService: ReadingProfileService,
|
||||||
|
private toastr: ToastrService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
|
||||||
|
this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => {
|
||||||
|
this.parentReadingProfile = parent;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.parentReadingProfile = this.readingProfile;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.settingsForm = this.readerSettingsService.getSettingsForm();
|
this.fontFamilies = this.bookService.getFontFamilies();
|
||||||
this.fontFamilies = this.readerSettingsService.getFontFamilies();
|
|
||||||
this.fontOptions = this.fontFamilies.map(f => f.title);
|
this.fontOptions = this.fontFamilies.map(f => f.title);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
this.setupSettings();
|
||||||
|
|
||||||
|
this.setTheme(this.readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme, false);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
// Emit first time so book reader gets the setting
|
||||||
|
this.readingDirection.emit(this.readingDirectionModel);
|
||||||
|
this.bookReaderWritingStyle.emit(this.writingStyleModel);
|
||||||
|
this.clickToPaginateChanged.emit(this.readingProfile.bookReaderTapToPaginate);
|
||||||
|
this.layoutModeUpdate.emit(this.readingProfile.bookReaderLayoutMode);
|
||||||
|
this.immersiveMode.emit(this.readingProfile.bookReaderImmersiveMode);
|
||||||
|
|
||||||
|
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||||
|
if (user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User needs to be loaded before we call this
|
||||||
|
this.resetSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSettings() {
|
||||||
|
if (!this.readingProfile) return;
|
||||||
|
|
||||||
|
if (this.readingProfile.bookReaderFontFamily === undefined) {
|
||||||
|
this.readingProfile.bookReaderFontFamily = 'default';
|
||||||
|
}
|
||||||
|
if (this.readingProfile.bookReaderFontSize === undefined || this.readingProfile.bookReaderFontSize < 50) {
|
||||||
|
this.readingProfile.bookReaderFontSize = 100;
|
||||||
|
}
|
||||||
|
if (this.readingProfile.bookReaderLineSpacing === undefined || this.readingProfile.bookReaderLineSpacing < 100) {
|
||||||
|
this.readingProfile.bookReaderLineSpacing = 100;
|
||||||
|
}
|
||||||
|
if (this.readingProfile.bookReaderMargin === undefined) {
|
||||||
|
this.readingProfile.bookReaderMargin = 0;
|
||||||
|
}
|
||||||
|
if (this.readingProfile.bookReaderReadingDirection === undefined) {
|
||||||
|
this.readingProfile.bookReaderReadingDirection = ReadingDirection.LeftToRight;
|
||||||
|
}
|
||||||
|
if (this.readingProfile.bookReaderWritingStyle === undefined) {
|
||||||
|
this.readingProfile.bookReaderWritingStyle = WritingStyle.Horizontal;
|
||||||
|
}
|
||||||
|
this.readingDirectionModel = this.readingProfile.bookReaderReadingDirection;
|
||||||
|
this.writingStyleModel = this.readingProfile.bookReaderWritingStyle;
|
||||||
|
|
||||||
|
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.readingProfile.bookReaderFontFamily, []));
|
||||||
|
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => {
|
||||||
|
const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family;
|
||||||
|
if (familyName === 'default') {
|
||||||
|
this.pageStyles['font-family'] = 'inherit';
|
||||||
|
} else {
|
||||||
|
this.pageStyles['font-family'] = "'" + familyName + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.styleUpdate.emit(this.pageStyles);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.readingProfile.bookReaderFontSize, []));
|
||||||
|
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||||
|
this.pageStyles['font-size'] = value + '%';
|
||||||
|
this.styleUpdate.emit(this.pageStyles);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.readingProfile.bookReaderTapToPaginate, []));
|
||||||
|
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||||
|
this.clickToPaginateChanged.emit(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.readingProfile.bookReaderLineSpacing, []));
|
||||||
|
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||||
|
this.pageStyles['line-height'] = value + '%';
|
||||||
|
this.styleUpdate.emit(this.pageStyles);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.readingProfile.bookReaderMargin, []));
|
||||||
|
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||||
|
this.pageStyles['margin-left'] = value + 'vw';
|
||||||
|
this.pageStyles['margin-right'] = value + 'vw';
|
||||||
|
this.styleUpdate.emit(this.pageStyles);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settingsForm.addControl('layoutMode', new FormControl(this.readingProfile.bookReaderLayoutMode, []));
|
||||||
|
this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => {
|
||||||
|
this.layoutModeUpdate.emit(layoutMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.readingProfile.bookReaderImmersiveMode, []));
|
||||||
|
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => {
|
||||||
|
if (immersiveMode) {
|
||||||
|
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
|
||||||
|
}
|
||||||
|
this.immersiveMode.emit(immersiveMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update implicit reading profile while changing settings
|
||||||
|
this.settingsForm.valueChanges.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
skip(1), // Skip the initial creation of the form, we do not want an implicit profile of this snapshot
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
tap(_ => this.updateImplicit())
|
||||||
|
).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSettings() {
|
resetSettings() {
|
||||||
this.readerSettingsService.resetSettings();
|
if (!this.readingProfile) return;
|
||||||
|
|
||||||
|
if (this.user) {
|
||||||
|
this.setPageStyles(this.readingProfile.bookReaderFontFamily, this.readingProfile.bookReaderFontSize + '%', this.readingProfile.bookReaderMargin + 'vw', this.readingProfile.bookReaderLineSpacing + '%');
|
||||||
|
} else {
|
||||||
|
this.setPageStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.readingProfile.bookReaderFontFamily);
|
||||||
|
this.settingsForm.get('bookReaderFontSize')?.setValue(this.readingProfile.bookReaderFontSize);
|
||||||
|
this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.readingProfile.bookReaderLineSpacing);
|
||||||
|
this.settingsForm.get('bookReaderMargin')?.setValue(this.readingProfile.bookReaderMargin);
|
||||||
|
this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.readingProfile.bookReaderReadingDirection);
|
||||||
|
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.readingProfile.bookReaderTapToPaginate);
|
||||||
|
this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.readingProfile.bookReaderLayoutMode);
|
||||||
|
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.readingProfile.bookReaderImmersiveMode);
|
||||||
|
this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.readingProfile.bookReaderWritingStyle);
|
||||||
|
|
||||||
|
this.cdRef.detectChanges();
|
||||||
|
this.styleUpdate.emit(this.pageStyles);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImplicit() {
|
||||||
|
this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({
|
||||||
|
next: newProfile => {
|
||||||
|
this.readingProfile = newProfile;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to be used by resetSettings. Pass items in with quantifiers
|
||||||
|
*/
|
||||||
|
setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string, colorTheme?: string) {
|
||||||
|
const windowWidth = window.innerWidth
|
||||||
|
|| this.document.documentElement.clientWidth
|
||||||
|
|| this.document.body.clientWidth;
|
||||||
|
|
||||||
|
|
||||||
|
let defaultMargin = '15vw';
|
||||||
|
if (windowWidth <= mobileBreakpointMarginOverride) {
|
||||||
|
defaultMargin = '5vw';
|
||||||
|
}
|
||||||
|
this.pageStyles = {
|
||||||
|
'font-family': fontFamily || this.pageStyles['font-family'] || 'default',
|
||||||
|
'font-size': fontSize || this.pageStyles['font-size'] || '100%',
|
||||||
|
'margin-left': margin || this.pageStyles['margin-left'] || defaultMargin,
|
||||||
|
'margin-right': margin || this.pageStyles['margin-right'] || defaultMargin,
|
||||||
|
'line-height': lineHeight || this.pageStyles['line-height'] || '100%'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(themeName: string, update: boolean = true) {
|
setTheme(themeName: string, update: boolean = true) {
|
||||||
this.readerSettingsService.setTheme(themeName, update);
|
const theme = this.themes.find(t => t.name === themeName);
|
||||||
|
this.activeTheme = theme;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
this.colorThemeUpdate.emit(theme);
|
||||||
|
|
||||||
|
if (update) {
|
||||||
|
this.updateImplicit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleReadingDirection() {
|
toggleReadingDirection() {
|
||||||
this.readerSettingsService.toggleReadingDirection();
|
if (this.readingDirectionModel === ReadingDirection.LeftToRight) {
|
||||||
|
this.readingDirectionModel = ReadingDirection.RightToLeft;
|
||||||
|
} else {
|
||||||
|
this.readingDirectionModel = ReadingDirection.LeftToRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
this.readingDirection.emit(this.readingDirectionModel);
|
||||||
|
this.updateImplicit();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleWritingStyle() {
|
toggleWritingStyle() {
|
||||||
this.readerSettingsService.toggleWritingStyle();
|
if (this.writingStyleModel === WritingStyle.Horizontal) {
|
||||||
|
this.writingStyleModel = WritingStyle.Vertical
|
||||||
|
} else {
|
||||||
|
this.writingStyleModel = WritingStyle.Horizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
this.bookReaderWritingStyle.emit(this.writingStyleModel);
|
||||||
|
this.updateImplicit();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFullscreen() {
|
toggleFullscreen() {
|
||||||
this.readerSettingsService.toggleFullscreen();
|
this.isFullscreen = !this.isFullscreen;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
this.fullscreen.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// menu only code
|
// menu only code
|
||||||
updateParentPref() {
|
updateParentPref() {
|
||||||
this.readerSettingsService.updateParentProfile();
|
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => {
|
||||||
|
this.readingProfile = newProfile;
|
||||||
|
this.toastr.success(translate('manga-reader.reading-profile-updated'));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createNewProfileFromImplicit() {
|
createNewProfileFromImplicit() {
|
||||||
this.readerSettingsService.createNewProfileFromImplicit();
|
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
|
||||||
|
this.readingProfile = newProfile;
|
||||||
|
this.parentReadingProfile = newProfile; // profile is no longer implicit
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
this.toastr.success(translate("manga-reader.reading-profile-promoted"));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private packReadingProfile(): ReadingProfile {
|
||||||
|
const modelSettings = this.settingsForm.getRawValue();
|
||||||
|
const data = {...this.readingProfile!};
|
||||||
|
data.bookReaderFontFamily = modelSettings.bookReaderFontFamily;
|
||||||
|
data.bookReaderFontSize = modelSettings.bookReaderFontSize
|
||||||
|
data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing;
|
||||||
|
data.bookReaderMargin = modelSettings.bookReaderMargin;
|
||||||
|
data.bookReaderTapToPaginate = modelSettings.bookReaderTapToPaginate;
|
||||||
|
data.bookReaderLayoutMode = modelSettings.layoutMode;
|
||||||
|
data.bookReaderImmersiveMode = modelSettings.bookReaderImmersiveMode;
|
||||||
|
|
||||||
|
data.bookReaderReadingDirection = this.readingDirectionModel;
|
||||||
|
data.bookReaderWritingStyle = this.writingStyleModel;
|
||||||
|
if (this.activeTheme) {
|
||||||
|
data.bookReaderThemeName = this.activeTheme.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly ReadingProfileKind = ReadingProfileKind;
|
protected readonly ReadingProfileKind = ReadingProfileKind;
|
||||||
protected readonly WritingStyle = WritingStyle;
|
|
||||||
protected readonly ReadingDirection = ReadingDirection;
|
|
||||||
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<ng-container *transloco="let t; prefix: 'personal-table-of-contents'">
|
|
||||||
@let ptoc = bookmark();
|
|
||||||
@if (ptoc) {
|
|
||||||
|
|
||||||
<div class="card clickable" (click)="goTo($event)">
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-title" [ngbTooltip]="ptoc.title" placement="left" container="body">
|
|
||||||
<span class="ellipsis">{{ptoc.title}}</span>
|
|
||||||
|
|
||||||
<button class="btn btn-icon ms-auto" (click)="remove($event)">
|
|
||||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
|
||||||
<span class="visually-hidden">{{t('delete', {bookmarkName: ptoc.title})}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="subtitle text-muted">
|
|
||||||
@if (ptoc.chapterTitle) {
|
|
||||||
Chapter "{{ptoc.chapterTitle}}" -
|
|
||||||
}
|
|
||||||
{{t('page', {value: ptoc.pageNumber})}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.card:hover {
|
|
||||||
background-color: var(--elevation-layer7)
|
|
||||||
}
|
|
||||||
.ellipsis {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import {Component, EventEmitter, input, Output} from '@angular/core';
|
|
||||||
import {PersonalToC} from "../../../_models/readers/personal-toc";
|
|
||||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-text-bookmark-item',
|
|
||||||
imports: [
|
|
||||||
NgbTooltip,
|
|
||||||
TranslocoDirective
|
|
||||||
],
|
|
||||||
templateUrl: './text-bookmark-item.component.html',
|
|
||||||
styleUrl: './text-bookmark-item.component.scss'
|
|
||||||
})
|
|
||||||
export class TextBookmarkItemComponent {
|
|
||||||
bookmark = input.required<PersonalToC>();
|
|
||||||
|
|
||||||
@Output() loadBookmark = new EventEmitter<PersonalToC>();
|
|
||||||
@Output() removeBookmark = new EventEmitter<PersonalToC>();
|
|
||||||
|
|
||||||
|
|
||||||
remove(evt: Event) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
evt.preventDefault();
|
|
||||||
|
|
||||||
this.removeBookmark.emit(this.bookmark());
|
|
||||||
}
|
|
||||||
|
|
||||||
goTo(evt: Event) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
evt.preventDefault();
|
|
||||||
|
|
||||||
this.loadBookmark.emit(this.bookmark());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
export enum HightlightColor {
|
|
||||||
Blue = 1,
|
|
||||||
Green = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Annotation {
|
|
||||||
id: number;
|
|
||||||
xpath: string;
|
|
||||||
endingXPath: string | null;
|
|
||||||
selectedText: string | null;
|
|
||||||
comment: string;
|
|
||||||
hightlightColor: HightlightColor;
|
|
||||||
containsSpoiler: boolean;
|
|
||||||
pageNumber: number;
|
|
||||||
|
|
||||||
|
|
||||||
chapterId: number;
|
|
||||||
|
|
||||||
ownerUserId: number;
|
|
||||||
ownerUsername: string;
|
|
||||||
createdUtc: string;
|
|
||||||
lastModifiedUtc: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import {MangaFormat} from "src/app/_models/manga-format";
|
import { MangaFormat } from "src/app/_models/manga-format";
|
||||||
|
|
||||||
export interface BookInfo {
|
export interface BookInfo {
|
||||||
bookTitle: string;
|
bookTitle: string;
|
||||||
seriesFormat: MangaFormat;
|
seriesFormat: MangaFormat;
|
||||||
seriesId: number;
|
seriesId: number;
|
||||||
libraryId: number;
|
libraryId: number;
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import {HightlightColor} from "./annotation";
|
|
||||||
|
|
||||||
export interface CreateAnnotationRequest {
|
|
||||||
libraryId: number;
|
|
||||||
volumeId: number;
|
|
||||||
chapterId: number;
|
|
||||||
xpath: string;
|
|
||||||
endingXPath: string | null;
|
|
||||||
selectedText: string | null;
|
|
||||||
comment: string | null;
|
|
||||||
hightlightColor: HightlightColor;
|
|
||||||
highlightCount: number;
|
|
||||||
containsSpoiler: boolean;
|
|
||||||
pageNumber: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import {Pipe, PipeTransform} from '@angular/core';
|
|
||||||
import {BookPageLayoutMode} from "../../_models/readers/book-page-layout-mode";
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'columnLayoutClass'
|
|
||||||
})
|
|
||||||
export class ColumnLayoutClassPipe implements PipeTransform {
|
|
||||||
|
|
||||||
transform(value: BookPageLayoutMode): string {
|
|
||||||
switch (value) {
|
|
||||||
case BookPageLayoutMode.Default:
|
|
||||||
return '';
|
|
||||||
case BookPageLayoutMode.Column1:
|
|
||||||
return 'column-layout-1';
|
|
||||||
case BookPageLayoutMode.Column2:
|
|
||||||
return 'column-layout-2';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import {Pipe, PipeTransform} from '@angular/core';
|
|
||||||
import {WritingStyle} from "../../_models/preferences/writing-style";
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'writingStyleClass'
|
|
||||||
})
|
|
||||||
export class WritingStyleClassPipe implements PipeTransform {
|
|
||||||
|
|
||||||
transform(value: WritingStyle): string {
|
|
||||||
switch (value) {
|
|
||||||
case WritingStyle.Horizontal:
|
|
||||||
return '';
|
|
||||||
case WritingStyle.Vertical:
|
|
||||||
return 'writing-style-vertical';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import {HttpClient} from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import {Injectable} from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {TextResonse} from 'src/app/_types/text-response';
|
import { TextResonse } from 'src/app/_types/text-response';
|
||||||
import {environment} from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import {BookChapterItem} from '../_models/book-chapter-item';
|
import { BookChapterItem } from '../_models/book-chapter-item';
|
||||||
import {BookInfo} from '../_models/book-info';
|
import { BookInfo } from '../_models/book-info';
|
||||||
|
|
||||||
export interface FontFamily {
|
export interface FontFamily {
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,8 +28,7 @@ export class BookService {
|
||||||
getFontFamilies(): Array<FontFamily> {
|
getFontFamilies(): Array<FontFamily> {
|
||||||
return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'},
|
return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'},
|
||||||
{title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'},
|
{title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'},
|
||||||
{title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'},
|
{title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}];
|
||||||
{title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookChapters(chapterId: number) {
|
getBookChapters(chapterId: number) {
|
||||||
|
|
@ -40,8 +39,8 @@ export class BookService {
|
||||||
return this.http.get<string>(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse);
|
return this.http.get<string>(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookInfo(chapterId: number, includeWordCounts: boolean = false) {
|
getBookInfo(chapterId: number) {
|
||||||
return this.http.get<BookInfo>(this.baseUrl + `book/${chapterId}/book-info?includeWordCounts=${includeWordCounts}`);
|
return this.http.get<BookInfo>(this.baseUrl + 'book/' + chapterId + '/book-info');
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookPageUrl(chapterId: number, page: number) {
|
getBookPageUrl(chapterId: number, page: number) {
|
||||||
|
|
|
||||||
|
|
@ -800,40 +800,12 @@
|
||||||
"book-line-overlay": {
|
"book-line-overlay": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"bookmark": "Bookmark",
|
"bookmark": "Bookmark",
|
||||||
"annotate": "Annotate",
|
|
||||||
"close": "{{common.close}}",
|
"close": "{{common.close}}",
|
||||||
"required-field": "{{common.required-field}}",
|
"required-field": "{{common.required-field}}",
|
||||||
"bookmark-label": "Bookmark Name",
|
"bookmark-label": "Bookmark Name",
|
||||||
"save": "{{common.save}}"
|
"save": "{{common.save}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"view-annotation-drawer": {
|
|
||||||
"title": "Annotations",
|
|
||||||
"close": "{{common.close}}"
|
|
||||||
},
|
|
||||||
|
|
||||||
"view-bookmark-drawer": {
|
|
||||||
"title": "Image Bookmarks",
|
|
||||||
"close": "{{common.close}}"
|
|
||||||
},
|
|
||||||
|
|
||||||
"view-toc-drawer": {
|
|
||||||
"title": "Table of Contents",
|
|
||||||
"close": "{{common.close}}",
|
|
||||||
"toc-header": "Book",
|
|
||||||
"personal-header": "Personal"
|
|
||||||
},
|
|
||||||
|
|
||||||
"epub-setting-drawer": {
|
|
||||||
"title": "Book Settings",
|
|
||||||
"close": "{{common.close}}"
|
|
||||||
},
|
|
||||||
|
|
||||||
"create-annotation-drawer": {
|
|
||||||
"title": "Create/Edit an Annotation",
|
|
||||||
"close": "{{common.close}}"
|
|
||||||
},
|
|
||||||
|
|
||||||
"book-reader": {
|
"book-reader": {
|
||||||
"title": "Book Settings",
|
"title": "Book Settings",
|
||||||
"page-label": "Page",
|
"page-label": "Page",
|
||||||
|
|
@ -865,18 +837,13 @@
|
||||||
"go-to-page-prompt": "There are {{totalPages}} pages. What page do you want to go to?",
|
"go-to-page-prompt": "There are {{totalPages}} pages. What page do you want to go to?",
|
||||||
|
|
||||||
"go-to-section": "Go to section",
|
"go-to-section": "Go to section",
|
||||||
"go-to-section-prompt": "There are {{totalSections}} sections. What section do you want to go to?",
|
"go-to-section-prompt": "There are {{totalSections}} sections. What section do you want to go to?"
|
||||||
|
|
||||||
"page-num-label": "Page {{page}}",
|
|
||||||
"completion-label": "{{percent}} complete"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"personal-table-of-contents": {
|
"personal-table-of-contents": {
|
||||||
"no-data": "Nothing Bookmarked yet",
|
"no-data": "Nothing Bookmarked yet",
|
||||||
"page": "Page {{value}}",
|
"page": "Page {{value}}",
|
||||||
"delete": "Delete {{bookmarkName}}",
|
"delete": "Delete {{bookmarkName}}"
|
||||||
"no-match": "No Bookmarks match filter",
|
|
||||||
"filter-label": "{{common.filter}}"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"confirm-email": {
|
"confirm-email": {
|
||||||
|
|
@ -2781,16 +2748,13 @@
|
||||||
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
|
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
|
||||||
"series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}",
|
"series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}",
|
||||||
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}",
|
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}",
|
||||||
"external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes.",
|
"external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes."
|
||||||
"confirm-delete-bookmark": "Are you sure you want to delete this Bookmark?"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"read-time-pipe": {
|
"read-time-pipe": {
|
||||||
"less-than-hour": "<1 Hour",
|
"less-than-hour": "<1 Hour",
|
||||||
"hour": "Hour",
|
"hour": "Hour",
|
||||||
"hours": "Hours",
|
"hours": "Hours"
|
||||||
"hour-left": "Hour left",
|
|
||||||
"hours-left": "Hours left"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"metadata-setting-field-pipe": {
|
"metadata-setting-field-pipe": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue