mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
New PDF Reader (#1324)
* Refactored all the code that opens the reader to use a unified function. Added new library and setup basic pdf reader route. * Progress saving is implemented. Targeting ES6 now. * Customized the toolbar to remove things we don't want, made the download button download with correct filename. Adjusted zoom setting to work well on first load regardless of device. * Stream the pdf file to the UI rather than handling the download ourselves. * Started implementing a custom toolbar. * Fixed up the jump bar calculations * Fixed filtering being broken * Pushing up for Robbie to cleanup the toolbar layout * Added an additional button. Working on logic while robbie takes styling * Tried to fix the code for robbie * Tweaks for fonts * Added button for book mode, but doesn't seem to work after renderer is built * Removed book mode * Removed the old image caching code for pdfs as it's not needed with new reader * Removed the interfaces to extract images from pdf. * Fixed original pagination area not scaling correctly * Integrated series remove events to library detail * Cleaned up the getter naming convention * Cleaned up some of the manga reader code to reduce cluter and improve re-use * Implemented Japanese parser support for volume and chapters. * Fixed a bug where resetting scroll in manga reader wasn't working * Fixed a bug where word count grew on each scan. * Removed unused variable * Ensure we calculate word count on files with their own cache timestamp * Adjusted size of reel headers * Put some code in for moving on original image with keyboard, but it's not in use. * Cleaned up the css for the pdf reader * Cleaned up the code * Tweaked the list item so we show scrollbar now when fully read
This commit is contained in:
parent
384fac68c4
commit
3ab3a10ae7
@ -71,6 +71,8 @@ namespace API.Tests.Parser
|
||||
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")]
|
||||
[InlineData("63권#200", "63")]
|
||||
[InlineData("시즌34삽화2", "34")]
|
||||
[InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1巻", "1")]
|
||||
[InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")]
|
||||
public void ParseVolumeTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
|
||||
@ -253,6 +255,7 @@ namespace API.Tests.Parser
|
||||
[InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")]
|
||||
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")]
|
||||
[InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")]
|
||||
[InlineData("[ハレム]ナナとカオル ~高校生のSMごっこ~ 第10話", "10")]
|
||||
public void ParseChaptersTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));
|
||||
|
@ -279,8 +279,8 @@ namespace API.Tests.Services
|
||||
}
|
||||
}
|
||||
};
|
||||
cs.GetCachedEpubFile(1, c);
|
||||
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedEpubFile(1, c));
|
||||
cs.GetCachedFile(c);
|
||||
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
@ -37,11 +38,34 @@ namespace API.Controllers
|
||||
{
|
||||
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
||||
var bookTitle = string.Empty;
|
||||
if (dto.SeriesFormat == MangaFormat.Epub)
|
||||
switch (dto.SeriesFormat)
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
|
||||
bookTitle = book.Title;
|
||||
case MangaFormat.Epub:
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
|
||||
bookTitle = book.Title;
|
||||
break;
|
||||
}
|
||||
case MangaFormat.Pdf:
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
if (string.IsNullOrEmpty(bookTitle))
|
||||
{
|
||||
// Override with filename
|
||||
bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case MangaFormat.Image:
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
break;
|
||||
case MangaFormat.Unknown:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
return Ok(new BookInfoDto()
|
||||
@ -209,7 +233,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult<string>> GetBookPage(int chapterId, [FromQuery] int page)
|
||||
{
|
||||
var chapter = await _cacheService.Ensure(chapterId);
|
||||
var path = _cacheService.GetCachedEpubFile(chapter.Id, chapter);
|
||||
var path = _cacheService.GetCachedFile(chapter);
|
||||
|
||||
using var book = await EpubReader.OpenBookAsync(path, BookService.BookReaderOptions);
|
||||
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
|
||||
|
@ -11,7 +11,6 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -45,6 +44,34 @@ namespace API.Controllers
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PDF for the chapterId.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">API Key for user to validate they have access</param>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("pdf")]
|
||||
public async Task<ActionResult> GetPdf(int chapterId)
|
||||
{
|
||||
|
||||
var chapter = await _cacheService.Ensure(chapterId);
|
||||
if (chapter == null) return BadRequest("There was an issue finding pdf file for reading");
|
||||
|
||||
try
|
||||
{
|
||||
var path = _cacheService.GetCachedFile(chapter);
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should.");
|
||||
|
||||
Response.AddCacheHeader(path, TimeSpan.FromMinutes(60).Seconds);
|
||||
return PhysicalFile(path, "application/pdf", Path.GetFileName(path), true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_cacheService.CleanupChapters(new []{ chapterId });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading.
|
||||
/// </summary>
|
||||
|
1570
API/Data/Migrations/20220615190640_LastFileAnalysis.Designer.cs
generated
Normal file
1570
API/Data/Migrations/20220615190640_LastFileAnalysis.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
API/Data/Migrations/20220615190640_LastFileAnalysis.cs
Normal file
27
API/Data/Migrations/20220615190640_LastFileAnalysis.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class LastFileAnalysis : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastFileAnalysis",
|
||||
table: "MangaFile",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastFileAnalysis",
|
||||
table: "MangaFile");
|
||||
}
|
||||
}
|
||||
}
|
@ -516,6 +516,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("Format")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastFileAnalysis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
27
API/Data/Repositories/MangaFileRepository.cs
Normal file
27
API/Data/Repositories/MangaFileRepository.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IMangaFileRepository
|
||||
{
|
||||
void Update(MangaFile file);
|
||||
}
|
||||
|
||||
public class MangaFileRepository : IMangaFileRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public MangaFileRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Update(MangaFile file)
|
||||
{
|
||||
_context.Entry(file).State = EntityState.Modified;
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ public interface IUnitOfWork
|
||||
IGenreRepository GenreRepository { get; }
|
||||
ITagRepository TagRepository { get; }
|
||||
ISiteThemeRepository SiteThemeRepository { get; }
|
||||
IMangaFileRepository MangaFileRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
@ -58,6 +59,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
|
||||
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
|
||||
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
@ -25,6 +25,10 @@ namespace API.Entities
|
||||
/// </summary>
|
||||
/// <remarks>This gets updated anytime the file is scanned</remarks>
|
||||
public DateTime LastModified { get; set; }
|
||||
/// <summary>
|
||||
/// Last time file analysis ran on this file
|
||||
/// </summary>
|
||||
public DateTime LastFileAnalysis { get; set; }
|
||||
|
||||
|
||||
// Relationship Mapping
|
||||
|
@ -14,6 +14,7 @@ public interface ICacheHelper
|
||||
bool CoverImageExists(string path);
|
||||
|
||||
bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
|
||||
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile);
|
||||
|
||||
}
|
||||
|
||||
@ -62,6 +63,25 @@ public class CacheHelper : ICacheHelper
|
||||
|| _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has the file been modified since last scan or is user forcing an update
|
||||
/// </summary>
|
||||
/// <param name="lastScan"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
/// <param name="firstFile"></param>
|
||||
/// <returns></returns>
|
||||
public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile)
|
||||
{
|
||||
if (firstFile == null) return false;
|
||||
if (forceUpdate) return true;
|
||||
return _fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan)
|
||||
|| _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified);
|
||||
// return firstFile != null &&
|
||||
// (!forceUpdate &&
|
||||
// !(_fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan)
|
||||
// || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a given coverImage path exists
|
||||
/// </summary>
|
||||
|
@ -126,6 +126,10 @@ namespace API.Parser
|
||||
new Regex(
|
||||
@"시즌(?<Volume>\d+(\-|~)?\d+?)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Japanese Volume: n巻 -> Volume n
|
||||
new Regex(
|
||||
@"(?<Volume>\d+(?:(\-)\d+)?)巻",
|
||||
MatchOptions, RegexTimeout),
|
||||
};
|
||||
|
||||
private static readonly Regex[] MangaSeriesRegex = new[]
|
||||
@ -368,6 +372,10 @@ namespace API.Parser
|
||||
new Regex(
|
||||
@"제?(?<Volume>\d+)권",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Japanese Volume: n巻 -> Volume n
|
||||
new Regex(
|
||||
@"(?<Volume>\d+(?:(\-)\d+)?)巻",
|
||||
MatchOptions, RegexTimeout),
|
||||
};
|
||||
|
||||
private static readonly Regex[] ComicChapterRegex = new[]
|
||||
@ -489,6 +497,10 @@ namespace API.Parser
|
||||
new Regex(
|
||||
@"제?(?<Chapter>\d+\.?\d+)(화|장)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話
|
||||
new Regex(
|
||||
@"第?(?<Chapter>\d+(?:.\d+|-\d+)?)話",
|
||||
MatchOptions, RegexTimeout),
|
||||
};
|
||||
private static readonly Regex[] MangaEditionRegex = {
|
||||
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
|
||||
|
@ -47,6 +47,7 @@ namespace API.Services
|
||||
/// </summary>
|
||||
/// <param name="fileFilePath"></param>
|
||||
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
||||
[Obsolete("This method of reading is no longer supported. Please use native pdf reader")]
|
||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||
|
||||
Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page);
|
||||
|
@ -29,7 +29,7 @@ namespace API.Services
|
||||
void CleanupBookmarks(IEnumerable<int> seriesIds);
|
||||
string GetCachedPagePath(Chapter chapter, int page);
|
||||
string GetCachedBookmarkPagePath(int seriesId, int page);
|
||||
string GetCachedEpubFile(int chapterId, Chapter chapter);
|
||||
string GetCachedFile(Chapter chapter);
|
||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
|
||||
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
|
||||
void CleanupBookmarkCache(int seriesId);
|
||||
@ -73,14 +73,13 @@ namespace API.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full path to the cached epub file. If the file does not exist, will fallback to the original.
|
||||
/// Returns the full path to the cached file. If the file does not exist, will fallback to the original.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
public string GetCachedEpubFile(int chapterId, Chapter chapter)
|
||||
public string GetCachedFile(Chapter chapter)
|
||||
{
|
||||
var extractPath = GetCachePath(chapterId);
|
||||
var extractPath = GetCachePath(chapter.Id);
|
||||
var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath));
|
||||
if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists))
|
||||
{
|
||||
@ -89,6 +88,7 @@ namespace API.Services
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Caches the files for the given chapter to CacheDirectory
|
||||
/// </summary>
|
||||
@ -136,25 +136,25 @@ namespace API.Services
|
||||
extraPath = file.Id + string.Empty;
|
||||
}
|
||||
|
||||
if (file.Format == MangaFormat.Archive)
|
||||
switch (file.Format)
|
||||
{
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
}
|
||||
else if (file.Format == MangaFormat.Pdf)
|
||||
{
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
}
|
||||
else if (file.Format == MangaFormat.Epub)
|
||||
{
|
||||
removeNonImages = false;
|
||||
if (!_directoryService.FileSystem.File.Exists(files[0].FilePath))
|
||||
case MangaFormat.Archive:
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
break;
|
||||
case MangaFormat.Epub:
|
||||
case MangaFormat.Pdf:
|
||||
{
|
||||
_logger.LogError("{Archive} does not exist on disk", files[0].FilePath);
|
||||
throw new KavitaException($"{files[0].FilePath} does not exist on disk");
|
||||
}
|
||||
removeNonImages = false;
|
||||
if (!_directoryService.FileSystem.File.Exists(files[0].FilePath))
|
||||
{
|
||||
_logger.LogError("{File} does not exist on disk", files[0].FilePath);
|
||||
throw new KavitaException($"{files[0].FilePath} does not exist on disk");
|
||||
}
|
||||
|
||||
_directoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
|
||||
_directoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -110,15 +110,13 @@ public class ReadingItemService : IReadingItemService
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case MangaFormat.Pdf:
|
||||
_bookService.ExtractPdfImages(fileFilePath, targetDirectory);
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
_archiveService.ExtractArchive(fileFilePath, targetDirectory);
|
||||
break;
|
||||
case MangaFormat.Image:
|
||||
_imageService.ExtractImages(fileFilePath, targetDirectory, imageCount);
|
||||
break;
|
||||
case MangaFormat.Pdf:
|
||||
case MangaFormat.Unknown:
|
||||
case MangaFormat.Epub:
|
||||
break;
|
||||
|
@ -55,7 +55,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
|
||||
var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var totalTime = 0L;
|
||||
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
@ -64,7 +63,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
|
||||
{
|
||||
if (chunkInfo.TotalChunks == 0) continue;
|
||||
totalTime += stopwatch.ElapsedMilliseconds;
|
||||
stopwatch.Restart();
|
||||
|
||||
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
|
||||
@ -145,26 +143,30 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true)
|
||||
{
|
||||
var isEpub = series.Format == MangaFormat.Epub;
|
||||
|
||||
series.WordCount = 0;
|
||||
foreach (var volume in series.Volumes)
|
||||
{
|
||||
volume.WordCount = 0;
|
||||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
// This compares if it's changed since a file scan only
|
||||
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate,
|
||||
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
|
||||
var firstFile = chapter.Files.FirstOrDefault();
|
||||
if (firstFile == null) return;
|
||||
if (!_cacheHelper.HasFileChangedSinceLastScan(firstFile.LastFileAnalysis, forceUpdate,
|
||||
firstFile))
|
||||
continue;
|
||||
|
||||
if (series.Format == MangaFormat.Epub)
|
||||
{
|
||||
long sum = 0;
|
||||
var fileCounter = 1;
|
||||
foreach (var file in chapter.Files.Select(file => file.FilePath))
|
||||
foreach (var file in chapter.Files)
|
||||
{
|
||||
var filePath = file.FilePath;
|
||||
var pageCounter = 1;
|
||||
try
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
|
||||
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
|
||||
|
||||
var totalPages = book.Content.Html.Values;
|
||||
foreach (var bookPage in totalPages)
|
||||
@ -174,7 +176,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
|
||||
ProgressEventType.Updated, useFileName ? file : series.Name));
|
||||
ProgressEventType.Updated, useFileName ? filePath : series.Name));
|
||||
sum += await GetWordCountFromHtml(bookPage);
|
||||
pageCounter++;
|
||||
}
|
||||
@ -190,6 +192,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
return;
|
||||
}
|
||||
|
||||
file.LastFileAnalysis = DateTime.Now;
|
||||
_unitOfWork.MangaFileRepository.Update(file);
|
||||
}
|
||||
|
||||
chapter.WordCount = sum;
|
||||
|
@ -30,7 +30,12 @@
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/assets",
|
||||
"src/site.webmanifest"
|
||||
"src/site.webmanifest",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ngx-extended-pdf-viewer/assets/",
|
||||
"output": "/assets/"
|
||||
}
|
||||
],
|
||||
"sourceMap": {
|
||||
"hidden": false,
|
||||
|
@ -4,7 +4,7 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"target": "es2020",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"node"
|
||||
|
14
UI/Web/package-lock.json
generated
14
UI/Web/package-lock.json
generated
@ -9230,6 +9230,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
|
||||
},
|
||||
"lodash.deburr": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz",
|
||||
"integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ=="
|
||||
},
|
||||
"lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
@ -9677,6 +9682,15 @@
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"ngx-extended-pdf-viewer": {
|
||||
"version": "13.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-13.5.2.tgz",
|
||||
"integrity": "sha512-dbGozWdfjHosHtJXRbM7zZQ8Zojdpv2/5e68767htvPRQ2JCUtRN+u6NwA59k+sNpNCliHhjaeFMXfWEWEHDMQ==",
|
||||
"requires": {
|
||||
"lodash.deburr": "^4.1.0",
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"ngx-file-drop": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-13.0.0.tgz",
|
||||
|
@ -39,6 +39,7 @@
|
||||
"lazysizes": "^5.3.2",
|
||||
"ng-circle-progress": "^1.6.0",
|
||||
"ngx-color-picker": "^12.0.0",
|
||||
"ngx-extended-pdf-viewer": "^13.5.2",
|
||||
"ngx-file-drop": "^13.0.0",
|
||||
"ngx-infinite-scroll": "^13.0.2",
|
||||
"ngx-toastr": "^14.2.1",
|
||||
|
@ -5,10 +5,14 @@ import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { HourEstimateRange } from '../_models/hour-estimate-range';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
|
||||
import { PageBookmark } from '../_models/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/progress-bookmark';
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
export const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@ -21,6 +25,22 @@ export class ReaderService {
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getNavigationArray(libraryId: number, seriesId: number, chapterId: number, format: MangaFormat) {
|
||||
if (format === undefined) format = MangaFormat.ARCHIVE;
|
||||
|
||||
if (format === MangaFormat.EPUB) {
|
||||
return ['library', libraryId, 'series', seriesId, 'book', chapterId];
|
||||
} else if (format === MangaFormat.PDF) {
|
||||
return ['library', libraryId, 'series', seriesId, 'pdf', chapterId];
|
||||
} else {
|
||||
return ['library', libraryId, 'series', seriesId, 'manga', chapterId];
|
||||
}
|
||||
}
|
||||
|
||||
downloadPdf(chapterId: number) {
|
||||
return this.baseUrl + 'reader/pdf?chapterId=' + chapterId;
|
||||
}
|
||||
|
||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
|
||||
}
|
||||
@ -51,7 +71,7 @@ export class ReaderService {
|
||||
|
||||
/**
|
||||
* Used exclusively for reading multiple bookmarks from a series
|
||||
* @param seriesId
|
||||
* @param seriesId
|
||||
*/
|
||||
getBookmarkInfo(seriesId: number) {
|
||||
return this.httpClient.get<BookmarkInfo>(this.baseUrl + 'reader/bookmark-info?seriesId=' + seriesId);
|
||||
@ -100,7 +120,7 @@ export class ReaderService {
|
||||
markVolumeUnread(seriesId: number, volumeId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-unread', {seriesId, volumeId});
|
||||
}
|
||||
|
||||
|
||||
|
||||
getNextChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) {
|
||||
if (readingListId > 0) {
|
||||
@ -150,7 +170,7 @@ export class ReaderService {
|
||||
/**
|
||||
* Parses out the page number from a Image src url
|
||||
* @param imageSrc Src attribute of Image
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
imageUrlToPageNum(imageSrc: string) {
|
||||
if (imageSrc === undefined || imageSrc === '') { return -1; }
|
||||
@ -192,7 +212,7 @@ export class ReaderService {
|
||||
}
|
||||
|
||||
enterFullscreen(el: Element, callback?: VoidFunction) {
|
||||
if (!document.fullscreenElement) {
|
||||
if (!document.fullscreenElement) {
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().then(() => {
|
||||
if (callback) {
|
||||
@ -214,7 +234,7 @@ export class ReaderService {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @returns If document is in fullscreen mode
|
||||
*/
|
||||
checkFullscreenMode() {
|
||||
|
@ -68,6 +68,10 @@ const routes: Routes = [
|
||||
path: ':libraryId/series/:seriesId/book',
|
||||
loadChildren: () => import('../app/book-reader/book-reader.module').then(m => m.BookReaderModule)
|
||||
},
|
||||
{
|
||||
path: ':libraryId/series/:seriesId/pdf',
|
||||
loadChildren: () => import('../app/pdf-reader/pdf-reader.module').then(m => m.PdfReaderModule)
|
||||
},
|
||||
]
|
||||
},
|
||||
{path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)},
|
||||
|
@ -7,7 +7,7 @@ import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { NavService } from 'src/app/_services/nav.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { BookService } from '../book.service';
|
||||
@ -40,8 +40,6 @@ interface HistoryPoint {
|
||||
}
|
||||
|
||||
const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up // TODO: Do I need this or can I change it with new fixed top height
|
||||
const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
|
||||
/**
|
||||
* Styles that should be applied on the top level book-content tag
|
||||
@ -515,7 +513,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) {
|
||||
// Redirect to the manga reader.
|
||||
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
this.router.navigate(['library', info.libraryId, 'series', info.seriesId, 'manga', this.chapterId], {queryParams: params});
|
||||
this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -222,10 +222,6 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]);
|
||||
} else {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
|
||||
}
|
||||
this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, this.chapter.id, chapter.files[0].format));
|
||||
}
|
||||
}
|
||||
|
@ -219,11 +219,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
}
|
||||
|
||||
const params = this.readerService.getQueryParamsObject(incognito, false);
|
||||
if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id], {queryParams: params});
|
||||
} else {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id], {queryParams: params});
|
||||
}
|
||||
this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, chapter.id, chapter.files[0].format), {queryParams: params});
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
@ -102,7 +102,7 @@
|
||||
</div>
|
||||
|
||||
<ng-template #jumpBar>
|
||||
<div class="jump-bar">
|
||||
<div class="jump-bar" *ngIf="jumpBarKeysToRender.length >= 4">
|
||||
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
||||
<button class="btn btn-link" (click)="scrollTo(jumpKey)">
|
||||
{{jumpKey.title}}
|
||||
|
@ -80,31 +80,26 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
@HostListener('window:resize', ['$event'])
|
||||
@HostListener('window:orientationchange', ['$event'])
|
||||
resizeJumpBar() {
|
||||
// TODO: Debounce this
|
||||
|
||||
const fullSize = (this.jumpBarKeys.length * keySize) - 20;
|
||||
const currentSize = (this.document.querySelector('.jump-bar')?.getBoundingClientRect().height || fullSize + 20) - 20;
|
||||
const fullSize = (this.jumpBarKeys.length * keySize);
|
||||
const currentSize = (this.document.querySelector('.viewport-container')?.getBoundingClientRect().height || 10) - 30;
|
||||
if (currentSize >= fullSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNumberOfKeys = parseInt(Math.round(currentSize / keySize) + '', 10);
|
||||
const targetNumberOfKeys = parseInt(Math.floor(currentSize / keySize) + '', 10);
|
||||
const removeCount = this.jumpBarKeys.length - targetNumberOfKeys - 3;
|
||||
if (removeCount <= 0) return;
|
||||
|
||||
|
||||
this.jumpBarKeysToRender = [];
|
||||
|
||||
|
||||
const removalTimes = Math.ceil(removeCount / 2);
|
||||
const midPoint = this.jumpBarKeys.length / 2;
|
||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[0]);
|
||||
this.removeFirstPartOfJumpBar(midPoint, removeCount / 2);
|
||||
this.removeFirstPartOfJumpBar(midPoint, removalTimes);
|
||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[midPoint]);
|
||||
this.removeSecondPartOfJumpBar(midPoint, removeCount / 2);
|
||||
this.removeSecondPartOfJumpBar(midPoint, removalTimes);
|
||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[this.jumpBarKeys.length - 1]);
|
||||
|
||||
//console.log('End product: ', this.jumpBarKeysToRender);
|
||||
// console.log('End key size: ', this.jumpBarKeysToRender.length);
|
||||
}
|
||||
|
||||
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
|
||||
@ -120,7 +115,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
removedIndexes.push(minIndex);
|
||||
}
|
||||
// console.log('second: removing ', removedIndexes);
|
||||
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
|
||||
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
|
||||
}
|
||||
@ -140,7 +134,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
removedIndexes.push(minIndex);
|
||||
}
|
||||
|
||||
// console.log('first: removing ', removedIndexes);
|
||||
for(let i = 1; i < midPoint; i++) {
|
||||
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
|
||||
}
|
||||
@ -151,9 +144,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; // ${this.pagination?.currentPage}_
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (this.filterSettings === undefined) {
|
||||
this.filterSettings = new FilterSettings();
|
||||
@ -166,10 +156,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
||||
this.resizeJumpBar();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.resizeJumpBar();
|
||||
// this.scroller.elementScrolled().pipe(
|
||||
// map(() => this.scroller.measureScrollOffset('bottom')),
|
||||
// pairwise(),
|
||||
|
@ -8,8 +8,7 @@
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages">
|
||||
<div class="progress-banner" *ngIf="totalPages > 0">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="carousel-container" *ngIf="items.length > 0 ">
|
||||
<div>
|
||||
<h3 style="display: inline-block;">
|
||||
<h3>
|
||||
<a href="javascript:void(0)" (click)="sectionClicked($event)" class="section-title" [ngClass]="{'non-selectable': !clickableTitle}">{{title}}</a>
|
||||
</h3>
|
||||
<div class="float-end" *ngIf="swiper">
|
||||
|
@ -35,3 +35,8 @@
|
||||
::ng-deep .last-carousel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: inline-block;
|
||||
font-size: 1.2rem;
|
||||
}
|
@ -19,6 +19,7 @@ import { NavService } from '../_services/nav.service';
|
||||
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-detail',
|
||||
@ -123,10 +124,15 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.hubService.messages$.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (event.event !== EVENTS.SeriesAdded) return;
|
||||
const seriesAdded = event.payload as SeriesAddedEvent;
|
||||
if (seriesAdded.libraryId !== this.libraryId) return;
|
||||
this.loadPage();
|
||||
if (event.event === EVENTS.SeriesAdded) {
|
||||
const seriesAdded = event.payload as SeriesAddedEvent;
|
||||
if (seriesAdded.libraryId !== this.libraryId) return;
|
||||
this.loadPage();
|
||||
} else if (event.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemoved = event.payload as SeriesRemovedEvent;
|
||||
if (seriesRemoved.libraryId !== this.libraryId) return;
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -194,17 +200,19 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadingSeries = true;
|
||||
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
|
||||
this.seriesService.getSeriesForLibrary(0, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
//this.series = series.result; // Non-infinite scroll version
|
||||
if (this.series.length === 0) {
|
||||
this.series = series.result;
|
||||
} else {
|
||||
if (direction === 1) {
|
||||
//this.series = [...this.series, ...series.result];
|
||||
this.series.concat(series.result);
|
||||
} else {
|
||||
this.series = [...series.result, ...this.series];
|
||||
}
|
||||
}
|
||||
this.series = series.result;
|
||||
|
||||
// For Pagination
|
||||
// if (this.series.length === 0) {
|
||||
// this.series = series.result;
|
||||
// } else {
|
||||
// if (direction === 1) {
|
||||
// //this.series = [...this.series, ...series.result];
|
||||
// this.series.concat(series.result);
|
||||
// } else {
|
||||
// this.series = [...series.result, ...this.series];
|
||||
// }
|
||||
// }
|
||||
|
||||
this.pagination = series.pagination;
|
||||
this.loadingSeries = false;
|
||||
|
15
UI/Web/src/app/manga-reader/fullscreen-icon.pipe.ts
Normal file
15
UI/Web/src/app/manga-reader/fullscreen-icon.pipe.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Returns the icon for the given state of fullscreen mode
|
||||
*/
|
||||
@Pipe({
|
||||
name: 'fullscreenIcon'
|
||||
})
|
||||
export class FullscreenIconPipe implements PipeTransform {
|
||||
|
||||
transform(isFullscreen: boolean): string {
|
||||
return isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt';
|
||||
}
|
||||
|
||||
}
|
@ -21,7 +21,11 @@
|
||||
<!-- {{this.pageNum}} -->
|
||||
<!-- {{readerService.imageUrlToPageNum(canvasImage.src)}}<ng-container *ngIf="ShouldRenderDoublePage && (this.pageNum + 1 <= maxPages - 1 && this.pageNum > 0)"> - {{PageNumber + 1}}</ng-container> -->
|
||||
|
||||
<button *ngIf="!bookmarkMode" class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="isCurrentPageBookmarked" title="{{isCurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{isCurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="visually-hidden">{{isCurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
|
||||
<button *ngIf="!bookmarkMode" class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked"
|
||||
title="{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()">
|
||||
<i class="{{CurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -47,7 +51,10 @@
|
||||
title="Previous Page" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')" [ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? ImageHeight: '25%'), 'left': (readerMode === ReaderMode.LeftRight && (this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.ORIGINAL) ? ImageWidth: 'inherit'), 'right': rightPaginationOffset + 'px'}">
|
||||
<div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"
|
||||
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? ImageHeight: '25%'),
|
||||
'left': 'inherit',
|
||||
'right': rightPaginationOffset + 'px'}">
|
||||
<div *ngIf="showClickOverlay">
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'right' : 'down'}}"
|
||||
title="Next Page" aria-hidden="true"></i>
|
||||
@ -56,12 +63,12 @@
|
||||
</div>
|
||||
|
||||
<div class="image-container {{getFittingOptionClass()}}" [ngClass]="{'d-none': renderWithCanvas, 'center-double': ShouldRenderDoublePage,
|
||||
'fit-to-width-double-offset' : this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.WIDTH && ShouldRenderDoublePage,
|
||||
'fit-to-height-double-offset': this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.HEIGHT && ShouldRenderDoublePage,
|
||||
'original-double-offset' : this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.ORIGINAL && ShouldRenderDoublePage,
|
||||
'fit-to-width-double-offset' : FittingOption === FITTING_OPTION.WIDTH && ShouldRenderDoublePage,
|
||||
'fit-to-height-double-offset': FittingOption === FITTING_OPTION.HEIGHT && ShouldRenderDoublePage,
|
||||
'original-double-offset' : FittingOption === FITTING_OPTION.ORIGINAL && ShouldRenderDoublePage,
|
||||
'reverse': ShouldRenderReverseDouble}">
|
||||
<img #image [src]="canvasImage.src" id="image-1"
|
||||
class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
|
||||
class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
|
||||
|
||||
<ng-container *ngIf="ShouldRenderDoublePage && (this.pageNum + 1 <= maxPages - 1 && this.pageNum > 0)">
|
||||
<img [src]="getPageUrl(PageNumber + 1)" id="image-2" class="image-2 {{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}} {{ShouldRenderReverseDouble ? 'reverse' : ''}}">
|
||||
@ -115,13 +122,13 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" title="Reading Mode" (click)="toggleReaderMode();resetMenuCloseTimer();">
|
||||
<i class="fa {{readerModeIcon}}" aria-hidden="true"></i>
|
||||
<i class="fa {{ReaderModeIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Reading Mode</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" title="{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
|
||||
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}}" aria-hidden="true"></i>
|
||||
<i class="fa {{this.isFullscreen | fullscreenIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -138,7 +145,7 @@
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="page-splitting" class="form-label">Image Splitting</label>
|
||||
<div class="split fa fa-image">
|
||||
<div class="{{splitIconClass}}"></div>
|
||||
<div class="{{SplitIconClass}}"></div>
|
||||
</div>
|
||||
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
||||
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text}}</option>
|
||||
|
@ -27,7 +27,6 @@ import { LibraryType } from '../_models/library';
|
||||
import { ShorcutsModalComponent } from '../reader-shared/_modals/shorcuts-modal/shorcuts-modal.component';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { LayoutMode } from './_models/layout-mode';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
||||
const PREFETCH_PAGES = 8;
|
||||
|
||||
@ -284,7 +283,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return (this.layoutMode === LayoutMode.DoubleReversed) && !this.isCoverImage();
|
||||
}
|
||||
|
||||
get isCurrentPageBookmarked() {
|
||||
get CurrentPageBookmarked() {
|
||||
return this.bookmarks.hasOwnProperty(this.pageNum);
|
||||
}
|
||||
|
||||
@ -302,21 +301,20 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
get ImageHeight() {
|
||||
// If we are a cover image and implied fit to screen, then we need to take screen height rather than image height
|
||||
if (this.isCoverImage() || this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.WIDTH) {
|
||||
if (this.isCoverImage() || this.FittingOption === FITTING_OPTION.WIDTH) {
|
||||
return this.WindowHeight;
|
||||
}
|
||||
return this.image?.nativeElement.height + 'px';
|
||||
}
|
||||
|
||||
|
||||
get RightPaginationOffset() {
|
||||
if (this.readerMode === ReaderMode.LeftRight && this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.HEIGHT) {
|
||||
if (this.readerMode === ReaderMode.LeftRight && this.FittingOption === FITTING_OPTION.HEIGHT) {
|
||||
return (this.readingArea?.nativeElement?.scrollLeft || 0) * -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get splitIconClass() {
|
||||
get SplitIconClass() {
|
||||
if (this.isSplitLeftToRight()) {
|
||||
return 'left-side';
|
||||
} else if (this.isNoSplit()) {
|
||||
@ -325,7 +323,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return 'right-side';
|
||||
}
|
||||
|
||||
get readerModeIcon() {
|
||||
get ReaderModeIcon() {
|
||||
switch(this.readerMode) {
|
||||
case ReaderMode.LeftRight:
|
||||
return 'fa-exchange-alt';
|
||||
@ -361,13 +359,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return FITTING_OPTION;
|
||||
}
|
||||
|
||||
get FittingOption() {
|
||||
return this.generalSettingsForm.get('fittingOption')?.value;
|
||||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||
public readerService: ReaderService, private location: Location,
|
||||
private formBuilder: FormBuilder, private navService: NavService,
|
||||
private toastr: ToastrService, private memberService: MemberService,
|
||||
public utilityService: UtilityService, private renderer: Renderer2,
|
||||
@Inject(DOCUMENT) private document: Document, private modalService: NgbModal,
|
||||
private seriesService: SeriesService) {
|
||||
@Inject(DOCUMENT) private document: Document, private modalService: NgbModal) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
@ -376,7 +377,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const libraryId = this.route.snapshot.paramMap.get('libraryId');
|
||||
const seriesId = this.route.snapshot.paramMap.get('seriesId');
|
||||
const chapterId = this.route.snapshot.paramMap.get('chapterId');
|
||||
|
||||
if (libraryId === null || seriesId === null || chapterId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
return;
|
||||
@ -394,89 +394,80 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.readingListId = parseInt(readingListId, 10);
|
||||
}
|
||||
|
||||
|
||||
this.continuousChaptersStack.push(this.chapterId);
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.readingDirection = this.user.preferences.readingDirection;
|
||||
this.scalingOption = this.user.preferences.scalingOption;
|
||||
this.pageSplitOption = this.user.preferences.pageSplitOption;
|
||||
this.autoCloseMenu = this.user.preferences.autoCloseMenu;
|
||||
this.readerMode = this.user.preferences.readerMode;
|
||||
this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single;
|
||||
this.backgroundColor = this.user.preferences.backgroundColor || '#000000';
|
||||
this.readerService.setOverrideStyles(this.backgroundColor);
|
||||
|
||||
|
||||
this.generalSettingsForm = this.formBuilder.group({
|
||||
autoCloseMenu: this.autoCloseMenu,
|
||||
pageSplitOption: this.pageSplitOption,
|
||||
fittingOption: this.translateScalingOption(this.scalingOption),
|
||||
layoutMode: this.layoutMode
|
||||
});
|
||||
|
||||
this.updateForm();
|
||||
|
||||
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
this.layoutMode = parseInt(val, 10);
|
||||
|
||||
if (this.layoutMode === LayoutMode.Single) {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.enable();
|
||||
|
||||
} else {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.FitSplit);
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable();
|
||||
|
||||
this.canvasImage2 = this.cachedImages.peek();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
|
||||
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
||||
const needsSplitting = this.isCoverImage();
|
||||
// If we need to split on a menu change, then we need to re-render.
|
||||
if (needsSplitting) {
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(progress => {
|
||||
if (!progress) {
|
||||
this.toggleMenu();
|
||||
this.toastr.info('Tap the image at any time to open the menu. You can configure different settings or go to page by clicking progress bar. Tap sides of image move to next/prev page.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If no user, we can't render
|
||||
if (!user) {
|
||||
this.router.navigateByUrl('/login');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.user = user;
|
||||
this.readingDirection = this.user.preferences.readingDirection;
|
||||
this.scalingOption = this.user.preferences.scalingOption;
|
||||
this.pageSplitOption = this.user.preferences.pageSplitOption;
|
||||
this.autoCloseMenu = this.user.preferences.autoCloseMenu;
|
||||
this.readerMode = this.user.preferences.readerMode;
|
||||
this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single;
|
||||
this.backgroundColor = this.user.preferences.backgroundColor || '#000000';
|
||||
this.readerService.setOverrideStyles(this.backgroundColor);
|
||||
|
||||
this.generalSettingsForm = this.formBuilder.group({
|
||||
autoCloseMenu: this.autoCloseMenu,
|
||||
pageSplitOption: this.pageSplitOption,
|
||||
fittingOption: this.translateScalingOption(this.scalingOption),
|
||||
layoutMode: this.layoutMode
|
||||
});
|
||||
|
||||
this.updateForm();
|
||||
|
||||
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
this.layoutMode = parseInt(val, 10);
|
||||
|
||||
if (this.layoutMode === LayoutMode.Single) {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.enable();
|
||||
} else {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.FitSplit);
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable();
|
||||
this.canvasImage2 = this.cachedImages.peek();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
|
||||
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
||||
const needsSplitting = this.isCoverImage();
|
||||
// If we need to split on a menu change, then we need to re-render.
|
||||
if (needsSplitting) {
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(progress => {
|
||||
if (!progress) {
|
||||
this.toggleMenu();
|
||||
this.toastr.info('Tap the image at any time to open the menu. You can configure different settings or go to page by clicking progress bar. Tap sides of image move to next/prev page.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(20), takeUntil(this.onDestroy)).subscribe(evt => {
|
||||
if (this.readerMode === ReaderMode.Webtoon) return;
|
||||
if (this.readerMode === ReaderMode.LeftRight && this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.HEIGHT) {
|
||||
if (this.readerMode === ReaderMode.LeftRight && this.FittingOption === FITTING_OPTION.HEIGHT) {
|
||||
this.rightPaginationOffset = (this.readingArea.nativeElement.scrollLeft) * -1;
|
||||
return;
|
||||
}
|
||||
this.rightPaginationOffset = 0;
|
||||
});
|
||||
this.getWindowDimensions();
|
||||
|
||||
if (this.canvas) {
|
||||
this.ctx = this.canvas.nativeElement.getContext('2d', { alpha: false });
|
||||
this.canvasImage.onload = () => this.renderPage();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -495,8 +486,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
switch (this.readerMode) {
|
||||
case ReaderMode.LeftRight:
|
||||
if (event.key === KEY_CODES.RIGHT_ARROW) {
|
||||
//if (!this.checkIfPaginationAllowed()) return;
|
||||
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
|
||||
} else if (event.key === KEY_CODES.LEFT_ARROW) {
|
||||
//if (!this.checkIfPaginationAllowed()) return;
|
||||
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
|
||||
}
|
||||
break;
|
||||
@ -531,6 +524,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// if there is scroll room and on original, then don't paginate
|
||||
checkIfPaginationAllowed() {
|
||||
// This is not used atm due to the complexity it adds with keyboard.
|
||||
if (this.readingArea === undefined || this.readingArea.nativeElement === undefined) return true;
|
||||
|
||||
const scrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
|
||||
const totalScrollWidth = this.readingArea?.nativeElement?.scrollWidth;
|
||||
// need to also check if there is scrolll needed
|
||||
|
||||
if (this.FittingOption === FITTING_OPTION.ORIGINAL && scrollLeft < totalScrollWidth) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
clickOverlayClass(side: 'right' | 'left') {
|
||||
if (!this.showClickOverlay) {
|
||||
return '';
|
||||
@ -593,10 +601,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}).pipe(take(1)).subscribe(results => {
|
||||
|
||||
|
||||
if (this.readingListMode && results.chapterInfo.seriesFormat === MangaFormat.EPUB) {
|
||||
if (this.readingListMode && (results.chapterInfo.seriesFormat === MangaFormat.EPUB || results.chapterInfo.seriesFormat === MangaFormat.PDF)) {
|
||||
// Redirect to the book reader.
|
||||
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
this.router.navigate(['library', results.chapterInfo.libraryId, 'series', results.chapterInfo.seriesId, 'book', this.chapterId], {queryParams: params});
|
||||
this.router.navigate(this.readerService.getNavigationArray(results.chapterInfo.libraryId, results.chapterInfo.seriesId, this.chapterId, results.chapterInfo.seriesFormat), {queryParams: params});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -831,7 +839,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
*/
|
||||
isNoSplit() {
|
||||
const splitValue = parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10);
|
||||
return splitValue === PageSplitOption.NoSplit || splitValue === PageSplitOption.FitSplit;
|
||||
return splitValue === PageSplitOption.NoSplit || splitValue === PageSplitOption.FitSplit;
|
||||
}
|
||||
|
||||
updateSplitPage() {
|
||||
@ -1072,7 +1080,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// Reset scroll on non HEIGHT Fits
|
||||
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
|
||||
this.document.body.scroll(0, 0)
|
||||
this.readingArea.nativeElement.scroll(0,0);
|
||||
}
|
||||
|
||||
|
||||
@ -1088,7 +1096,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|| document.body.clientHeight;
|
||||
|
||||
const needsSplitting = this.isCoverImage();
|
||||
let newScale = this.generalSettingsForm.get('fittingOption')?.value;
|
||||
let newScale = this.FittingOption;
|
||||
const widthRatio = windowWidth / (this.canvasImage.width / (needsSplitting ? 2 : 1));
|
||||
const heightRatio = windowHeight / (this.canvasImage.height);
|
||||
|
||||
@ -1340,7 +1348,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
bookmarkPage() {
|
||||
const pageNum = this.pageNum;
|
||||
|
||||
if (this.isCurrentPageBookmarked) {
|
||||
if (this.CurrentPageBookmarked) {
|
||||
let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
|
||||
if (this.layoutMode === LayoutMode.Double) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
|
||||
forkJoin(apis).pipe(take(1)).subscribe(() => {
|
||||
@ -1394,16 +1402,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
getWindowDimensions() {
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
return [windowWidth, windowHeight];
|
||||
}
|
||||
|
||||
openShortcutModal() {
|
||||
let ref = this.modalService.open(ShorcutsModalComponent, { scrollable: true, size: 'md' });
|
||||
ref.componentInstance.shortcuts = [
|
||||
|
@ -8,11 +8,13 @@ import { SharedModule } from '../shared/shared.module';
|
||||
import { NgxSliderModule } from '@angular-slider/ngx-slider';
|
||||
import { InfiniteScrollerComponent } from './infinite-scroller/infinite-scroller.component';
|
||||
import { ReaderSharedModule } from '../reader-shared/reader-shared.module';
|
||||
import { FullscreenIconPipe } from './fullscreen-icon.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
MangaReaderComponent,
|
||||
InfiniteScrollerComponent
|
||||
InfiniteScrollerComponent,
|
||||
FullscreenIconPipe
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
21
UI/Web/src/app/pdf-reader/pdf-reader.module.ts
Normal file
21
UI/Web/src/app/pdf-reader/pdf-reader.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PdfReaderComponent } from './pdf-reader/pdf-reader.component';
|
||||
import { PdfReaderRoutingModule } from './pdf-reader.router.module';
|
||||
import { NgxExtendedPdfViewerModule } from 'ngx-extended-pdf-viewer';
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
PdfReaderComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PdfReaderRoutingModule,
|
||||
NgxExtendedPdfViewerModule,
|
||||
NgbTooltipModule
|
||||
]
|
||||
})
|
||||
export class PdfReaderModule { }
|
17
UI/Web/src/app/pdf-reader/pdf-reader.router.module.ts
Normal file
17
UI/Web/src/app/pdf-reader/pdf-reader.router.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { PdfReaderComponent } from './pdf-reader/pdf-reader.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: ':chapterId',
|
||||
component: PdfReaderComponent,
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes), ],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class PdfReaderRoutingModule { }
|
@ -0,0 +1,79 @@
|
||||
<div class="{{theme}}">
|
||||
<ngx-extended-pdf-viewer
|
||||
#pdfViewer
|
||||
[src]="readerService.downloadPdf(this.chapterId)"
|
||||
height="100vh"
|
||||
[(page)]="currentPage"
|
||||
[textLayer]="true"
|
||||
[useBrowserLocale]="false"
|
||||
[showHandToolButton]="true"
|
||||
[showOpenFileButton]="false"
|
||||
[showPrintButton]="false"
|
||||
[showBookmarkButton]="false"
|
||||
[showRotateButton]="false"
|
||||
[showDownloadButton]="false"
|
||||
[showPropertiesButton]="false"
|
||||
[(zoom)]="zoomSetting"
|
||||
[showSecondaryToolbarButton]="true"
|
||||
|
||||
[showBorders]="true"
|
||||
[theme]="theme"
|
||||
[formTheme]="theme"
|
||||
[backgroundColor]="backgroundColor"
|
||||
[customToolbar]="multiToolbar"
|
||||
|
||||
(pageChange)="saveProgress()"
|
||||
>
|
||||
|
||||
</ngx-extended-pdf-viewer>
|
||||
|
||||
<ng-template #multiToolbar>
|
||||
<div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}"> <!--action-bar row g-0 justify-content-between-->
|
||||
<div id="toolbarViewerLeft">
|
||||
<pdf-toggle-sidebar></pdf-toggle-sidebar>
|
||||
<pdf-find-button></pdf-find-button>
|
||||
<pdf-paging-area></pdf-paging-area>
|
||||
</div>
|
||||
|
||||
<div id="toolbarViewerRight">
|
||||
<pdf-hand-tool></pdf-hand-tool>
|
||||
<pdf-select-tool></pdf-select-tool>
|
||||
<pdf-presentation-mode></pdf-presentation-mode>
|
||||
|
||||
|
||||
<!-- This is not yet supported by the underlying library
|
||||
<button (click)="toggleBookPageMode()" class="btn btn-icon toolbarButton">
|
||||
<i class="toolbar-icon fa-solid {{this.bookMode !== 'book' ? 'fa-book' : 'fa-book-open'}}" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{this.bookMode !== 'book' ? 'Book Mode' : 'Normal Mode'}}</span>
|
||||
</button> -->
|
||||
|
||||
<button class="btn btn-icon toolbarButton" [ngbTooltip]="bookTitle">
|
||||
<i class="toolbar-icon fa-solid fa-info" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">
|
||||
{{bookTitle}}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button *ngIf="incognitoMode" (click)="turnOffIncognito()" class="btn btn-icon toolbarButton">
|
||||
<i class="toolbar-icon fa fa-glasses" [ngStyle]="{color: fontColor}" aria-hidden="true"></i><span class="visually-hidden">Incognito Mode</span>
|
||||
</button>
|
||||
|
||||
<!-- This is pretty experimental, so it might not work perfectly -->
|
||||
<button (click)="toggleTheme()" class="btn btn-icon toolbarButton">
|
||||
<i class="toolbar-icon fa-solid {{this.theme === 'light' ? 'fa-sun' : 'fa-moon'}}" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{this.theme === 'light' ? 'Light Theme' : 'Dark Theme'}}</span>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-icon col-2 col-xs-1 toolbarButton" (click)="closeReader()">
|
||||
<i class="toolbar-icon fa fa-times-circle" aria-hidden="true" [ngStyle]="{color: fontColor}"></i>
|
||||
<span class="visually-hidden">Close Reader</span>
|
||||
</button>
|
||||
|
||||
<div class="verticalToolbarSeparator hiddenSmallView"></div>
|
||||
<pdf-toggle-secondary-toolbar></pdf-toggle-secondary-toolbar>
|
||||
</div>
|
||||
<pdf-zoom-toolbar ></pdf-zoom-toolbar>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</div>
|
@ -0,0 +1,12 @@
|
||||
.toolbar-icon {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
margin: 8px 0 4px !important;
|
||||
}
|
||||
|
||||
// Override since it's not coming from library
|
||||
::ng-deep #presentationMode {
|
||||
margin: 3px 0 4px !important;
|
||||
}
|
191
UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.ts
Normal file
191
UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PageViewModeType } from 'ngx-extended-pdf-viewer';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, take } from 'rxjs';
|
||||
import { BookService } from 'src/app/book-reader/book.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { NavService } from 'src/app/_services/nav.service';
|
||||
import { CHAPTER_ID_DOESNT_EXIST, ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { ThemeService } from 'src/app/_services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pdf-reader',
|
||||
templateUrl: './pdf-reader.component.html',
|
||||
styleUrls: ['./pdf-reader.component.scss']
|
||||
})
|
||||
export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
libraryId!: number;
|
||||
seriesId!: number;
|
||||
volumeId!: number;
|
||||
chapterId!: number;
|
||||
chapter!: Chapter;
|
||||
user!: User;
|
||||
|
||||
/**
|
||||
* Reading List id. Defaults to -1.
|
||||
*/
|
||||
readingListId: number = CHAPTER_ID_DOESNT_EXIST;
|
||||
|
||||
/**
|
||||
* If this is true, no progress will be saved.
|
||||
*/
|
||||
incognitoMode: boolean = false;
|
||||
|
||||
/**
|
||||
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
|
||||
*/
|
||||
readingListMode: boolean = false;
|
||||
|
||||
/**
|
||||
* Current Page number
|
||||
*/
|
||||
currentPage: number = 1;
|
||||
/**
|
||||
* Total pages
|
||||
*/
|
||||
maxPages: number = 1;
|
||||
bookTitle: string = '';
|
||||
|
||||
zoomSetting: string | number = 'auto';
|
||||
|
||||
theme: 'dark' | 'light' = 'light';
|
||||
themeMap: {[key:string]: {background: string, font: string}} = {
|
||||
'dark': {'background': '#292929', 'font': '#d9d9d9'},
|
||||
'light': {'background': '#f9f9f9', 'font': '#5a5a5a'}
|
||||
}
|
||||
backgroundColor: string = this.themeMap[this.theme].background;
|
||||
fontColor: string = this.themeMap[this.theme].font;
|
||||
|
||||
isLoading: boolean = false;
|
||||
|
||||
/**
|
||||
* This can't be updated dynamically:
|
||||
* https://github.com/stephanrauh/ngx-extended-pdf-viewer/issues/1415
|
||||
*/
|
||||
bookMode: PageViewModeType = 'multiple';
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||
private seriesService: SeriesService, public readerService: ReaderService,
|
||||
private navService: NavService, private toastr: ToastrService,
|
||||
private bookService: BookService, private themeService: ThemeService, private location: Location) {
|
||||
this.navService.hideNavBar();
|
||||
this.themeService.clearThemes();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => {
|
||||
this.themeService.setTheme(theme.name);
|
||||
});
|
||||
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.readerService.exitFullscreen();
|
||||
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const libraryId = this.route.snapshot.paramMap.get('libraryId');
|
||||
const seriesId = this.route.snapshot.paramMap.get('seriesId');
|
||||
const chapterId = this.route.snapshot.paramMap.get('chapterId');
|
||||
|
||||
if (libraryId === null || seriesId === null || chapterId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
return;
|
||||
}
|
||||
|
||||
this.libraryId = parseInt(libraryId, 10);
|
||||
this.seriesId = parseInt(seriesId, 10);
|
||||
this.chapterId = parseInt(chapterId, 10);
|
||||
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
|
||||
|
||||
|
||||
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
|
||||
if (readingListId != null) {
|
||||
this.readingListMode = true;
|
||||
this.readingListId = parseInt(readingListId, 10);
|
||||
}
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bookService.getBookInfo(this.chapterId).subscribe(info => {
|
||||
this.volumeId = info.volumeId;
|
||||
this.bookTitle = info.bookTitle;
|
||||
});
|
||||
|
||||
this.readerService.getProgress(this.chapterId).subscribe(progress => {
|
||||
this.currentPage = progress.pageNum || 1;
|
||||
});
|
||||
|
||||
this.seriesService.getChapter(this.chapterId).subscribe(chapter => {
|
||||
this.maxPages = chapter.pages;
|
||||
|
||||
if (this.currentPage >= this.maxPages) {
|
||||
this.currentPage = this.maxPages - 1;
|
||||
this.saveProgress();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state
|
||||
*/
|
||||
turnOffIncognito() {
|
||||
this.incognitoMode = false;
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
this.toastr.info('Incognito mode is off. Progress will now start being tracked.');
|
||||
this.saveProgress();
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
if (this.theme === 'dark') {
|
||||
this.theme = 'light';
|
||||
} else {
|
||||
this.theme = 'dark';
|
||||
}
|
||||
this.backgroundColor = this.themeMap[this.theme].background;
|
||||
this.fontColor = this.themeMap[this.theme].font;
|
||||
}
|
||||
|
||||
toggleBookPageMode() {
|
||||
if (this.bookMode === 'book') {
|
||||
this.bookMode = 'multiple';
|
||||
} else {
|
||||
this.bookMode = 'book';
|
||||
}
|
||||
}
|
||||
|
||||
saveProgress() {
|
||||
if (this.incognitoMode) return;
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {});
|
||||
}
|
||||
|
||||
closeReader() {
|
||||
if (this.readingListMode) {
|
||||
this.router.navigateByUrl('lists/' + this.readingListId);
|
||||
} else {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -118,7 +118,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
reader = 'book;'
|
||||
}
|
||||
const params = this.readerService.getQueryParamsObject(false, true, this.readingList.id);
|
||||
this.router.navigate(['library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], {queryParams: params});
|
||||
this.router.navigate(this.readerService.getNavigationArray(item.libraryId, item.seriesId, item.chapterId, item.seriesFormat), {queryParams: params});
|
||||
}
|
||||
|
||||
handleReadingListActionCallback(action: Action, readingList: ReadingList) {
|
||||
@ -194,10 +194,6 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentlyReadingChapter.seriesFormat === MangaFormat.EPUB) {
|
||||
this.router.navigate(['library', currentlyReadingChapter.libraryId, 'series', currentlyReadingChapter.seriesId, 'book', currentlyReadingChapter.chapterId], {queryParams: {readingListId: this.readingList.id}});
|
||||
} else {
|
||||
this.router.navigate(['library', currentlyReadingChapter.libraryId, 'series', currentlyReadingChapter.seriesId, 'manga', currentlyReadingChapter.chapterId], {queryParams: {readingListId: this.readingList.id}});
|
||||
}
|
||||
this.router.navigate(this.readerService.getNavigationArray(currentlyReadingChapter.libraryId, currentlyReadingChapter.seriesId, currentlyReadingChapter.chapterId, currentlyReadingChapter.seriesFormat), {queryParams: {readingListId: this.readingList.id}});
|
||||
}
|
||||
}
|
||||
|
@ -602,12 +602,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.toastr.error('There are no pages. Kavita was not able to read this archive.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.series?.id, 'book', chapter.id], {queryParams: {incognitoMode}});
|
||||
} else {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.series?.id, 'manga', chapter.id], {queryParams: {incognitoMode}});
|
||||
}
|
||||
this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, chapter.id, chapter.files[0].format), {queryParams: {incognitoMode}});
|
||||
}
|
||||
|
||||
openVolume(volume: Volume) {
|
||||
|
@ -12,7 +12,7 @@ import { download, Download } from '../_models/download';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { catchError, throttleTime } from 'rxjs/operators';
|
||||
|
||||
const DEBOUNCE_TIME = 100;
|
||||
export const DEBOUNCE_TIME = 100;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -60,7 +60,7 @@ export class DownloadService {
|
||||
downloadChapter(chapter: Chapter) {
|
||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { //NOTE: DO I need debounceTime since I have throttleTime()?
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
|
||||
this.save(blob, filename)
|
||||
}));
|
||||
}
|
||||
|
@ -229,4 +229,14 @@ export class UtilityService {
|
||||
|
||||
return paginatedVariable;
|
||||
}
|
||||
|
||||
getWindowDimensions() {
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
return [windowWidth, windowHeight];
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "es2015",
|
||||
"target": "ES6",
|
||||
"module": "es2020",
|
||||
"lib": [
|
||||
"es2019",
|
||||
|
Loading…
x
Reference in New Issue
Block a user