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:
Joseph Milazzo 2022-06-15 16:43:32 -05:00 committed by GitHub
parent 384fac68c4
commit 3ab3a10ae7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 2309 additions and 208 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

View File

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

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

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -4,7 +4,7 @@
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"target": "es2020",
"types": [
"jasmine",
"node"

View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">

View File

@ -35,3 +35,8 @@
::ng-deep .last-carousel {
margin-bottom: 0;
}
h3 {
display: inline-block;
font-size: 1.2rem;
}

View File

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

View 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';
}
}

View File

@ -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>&nbsp;
<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>

View File

@ -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 = [

View File

@ -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,

View 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 { }

View 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 { }

View File

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

View File

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

View 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();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015",
"target": "ES6",
"module": "es2020",
"lib": [
"es2019",