From cec104a39c2e53da589ae9034357f30946061928 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Wed, 23 Nov 2022 11:12:58 -0600 Subject: [PATCH] [Experimental] Split Renderers - Double & Double (Manga) fixes (#1667) * Updated swiper and some packages for reported security issues * Fixed reading lists promotion not working * Refactor RenameFileForCopy to use iterative recursion, rather than functional. * Ensured that bookmarks are fetched and ordered by Created date. * Fixed a bug where bookmarks were coming back in the correct order, but due to filenames, would not sort correctly. * Default installs to Debug log level given errors users have and Debug not being too noisy * Added jumpbar to bookmarks page * Now added jumpbar to bookmarks * Refactored some code into pipes and added some debug messaging for prefetcher * Try loading next and prev chapter's first/last page to cache so it renders faster * Updated GetImage to do a bound check on max page. Fixed a critical bug in how manga reader updates image elements src to prefetch/load pages. I was not creating a new reference which broke Angular's ability to update DOM on changes. * Refactored the image setting code to use a single method which tries to use a cached image always. * Refactored code to use getPage which favors cache and simplifies image creation code * Started the work to split the canvas renderer into it's own component * Refactored a lot of common methods into a service for the reader to support the upcoming renderer split * Moved components to nested folder. Refactored more code to streamline image sending to child renderer. Added notes across the code to help streamline flow of data and who owns what. * Swapped out SQLite for Memory, but the one from hangfire. Added DisableConcurrentExecution on ProcessChange to avoid duplication when multiple threads execute at once. * Basic split right to left is working with canvas renderer * Left to right and right to left now work * Fixed a bug where pagesplitoption wasn't being updated when modifying menu * Canvas rendering still has a bug with switching between right to left -> left to right on the re-render, it will choose a bad state. All else works fine with it. * Updated canvas renderer to implement the ImageRenderer interface * Canvas renderer is done * Setup single renderer. Need to figure out how to share CSS between renderers and also share some global stuff, like image height. * Refactored code so that image-container is within the renderers themselves. Still broken in scaling, but working towards a solution. * Added double click to shortcut menu * Moved image containers within the renderers * Pushing up for Robbie * nothing new * Move common css to a single scss file * More css consolidation * Fixed a npe in isWideImage * Refactored page updates to renderers to include max pages. Rewrote most of renderer into observables. * Moved bookmark for second page to double renderer * Started hooking in double renderer renderPage() * Fixed height scaling, but now canvas renderer is broken again * Fixed a bug with canvas renderer not moving to next page. Streamlined the code for getting page amounts from the dfferent renderers * Added double click to bookmark for canvas * Stashing the code and taking a break * Nothing much, buffer is still broken * Got double renderer to render at least one page * Double renderer now has access to 5 images at any time, so it can make appropriate decisions on when to render double pages. * Fixed up double rendererer moving backward page calc * Forward logic seems to be working * Cleaned up dead code after testing * Moved a few loggers in folder watching to trace * Everything seems to work fine, time to do double manga renderer * Moved some css around and added the reverse double component * Only execute renderer's pipes when in the correct mode * Still working on double renderer * Fixed scaling issues on double * Updating double logic - Fixed: Fixed an issue where a second page would render when current page was wide. * Hooked up double renderer * Made changes but not sure if im making progress * double manga fixes * Claned some of robbies code * Fixing last page bug * Library Settings Modal + New Library Settings (#1660) * Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump versions by dotnet-bump-version. * UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. * Fixed GA (#1664) * Bump versions by dotnet-bump-version. * Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666) * Fixed typeahead and updated manga reader to new layout structure * Fixed book reader fonts lookups * Fixed up some build issues * Fixed a bad import of css image * Some cleanup and rewrote how we log out data. * Renderer can be null on first load when performing some work. * Library Settings Modal + New Library Settings (#1660) * Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. * Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666) * Post merge cleanup * Again moving the file Signed-off-by: dependabot[bot] Co-authored-by: Robbie Davis Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- API/Services/Tasks/Scanner/LibraryWatcher.cs | 6 +- UI/Web/src/_manga-reader-common.scss | 73 ++ .../_models/{readers => }/page-layout-mode.ts | 0 .../app/_models/preferences/preferences.ts | 2 +- .../library-access-modal.component.ts | 2 +- .../library-selector.component.ts | 2 +- .../book-reader/book-reader.component.scss | 14 +- .../book-reader/book-reader.component.ts | 4 +- .../edit-collection-tags.component.ts | 2 +- .../canvas-renderer.component.html | 6 + .../canvas-renderer.component.scss | 25 + .../canvas-renderer.component.ts | 249 +++++++ .../double-renderer.component.html | 18 + .../double-renderer.component.scss | 45 ++ .../double-renderer.component.ts | 324 +++++++++ .../double-reverse-renderer.component.html | 18 + .../double-reverse-renderer.component.scss | 64 ++ .../double-reverse-renderer.component.ts | 499 +++++++++++++ .../infinite-scroller.component.html | 0 .../infinite-scroller.component.scss | 0 .../infinite-scroller.component.ts | 6 +- .../manga-reader}/manga-reader.component.html | 87 ++- .../manga-reader}/manga-reader.component.scss | 109 +-- .../manga-reader}/manga-reader.component.ts | 683 ++++++------------ .../single-renderer.component.html | 10 + .../single-renderer.component.scss | 1 + .../single-renderer.component.ts | 141 ++++ .../manga-reader/_models/reader-setting.ts | 13 + .../src/app/manga-reader/_models/renderer.ts | 45 ++ .../_series/managa-reader.service.ts | 138 ++++ .../app/manga-reader/manga-reader.module.ts | 14 +- .../manga-reader.router.module.ts | 2 +- .../src/app/manga-reader/swipe.directive.ts | 39 + .../splash-container.component.scss | 2 +- .../series-detail/series-detail.component.ts | 2 +- .../_components/typeahead.component.ts | 26 +- .../user-preferences.component.ts | 4 +- UI/Web/src/theme/utilities/_animations.scss | 9 + 38 files changed, 2065 insertions(+), 619 deletions(-) create mode 100644 UI/Web/src/_manga-reader-common.scss rename UI/Web/src/app/_models/{readers => }/page-layout-mode.ts (100%) create mode 100644 UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html create mode 100644 UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.scss create mode 100644 UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts create mode 100644 UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html create mode 100644 UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.scss create mode 100644 UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts create mode 100644 UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html create mode 100644 UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss create mode 100644 UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts rename UI/Web/src/app/manga-reader/{ => _components}/infinite-scroller/infinite-scroller.component.html (100%) rename UI/Web/src/app/manga-reader/{ => _components}/infinite-scroller/infinite-scroller.component.scss (100%) rename UI/Web/src/app/manga-reader/{ => _components}/infinite-scroller/infinite-scroller.component.ts (99%) rename UI/Web/src/app/manga-reader/{ => _components/manga-reader}/manga-reader.component.html (80%) rename UI/Web/src/app/manga-reader/{ => _components/manga-reader}/manga-reader.component.scss (74%) rename UI/Web/src/app/manga-reader/{ => _components/manga-reader}/manga-reader.component.ts (68%) create mode 100644 UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html create mode 100644 UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.scss create mode 100644 UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts create mode 100644 UI/Web/src/app/manga-reader/_models/reader-setting.ts create mode 100644 UI/Web/src/app/manga-reader/_models/renderer.ts create mode 100644 UI/Web/src/app/manga-reader/_series/managa-reader.service.ts create mode 100644 UI/Web/src/app/manga-reader/swipe.directive.ts diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 183793457..6cfafa575 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -236,17 +236,17 @@ public class LibraryWatcher : ILibraryWatcher private string GetFolder(string filePath, IEnumerable libraryFolders) { var parentDirectory = _directoryService.GetParentDirectoryName(filePath); - _logger.LogDebug("[LibraryWatcher] Parent Directory: {ParentDirectory}", parentDirectory); + _logger.LogTrace("[LibraryWatcher] Parent Directory: {ParentDirectory}", parentDirectory); if (string.IsNullOrEmpty(parentDirectory)) return string.Empty; // We need to find the library this creation belongs to // Multiple libraries can point to the same base folder. In this case, we need use FirstOrDefault var libraryFolder = libraryFolders.FirstOrDefault(f => parentDirectory.Contains(f)); - _logger.LogDebug("[LibraryWatcher] Library Folder: {LibraryFolder}", libraryFolder); + _logger.LogTrace("[LibraryWatcher] Library Folder: {LibraryFolder}", libraryFolder); if (string.IsNullOrEmpty(libraryFolder)) return string.Empty; var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList(); - _logger.LogDebug("[LibraryWatcher] Root Folders: {RootFolders}", rootFolder); + _logger.LogTrace("[LibraryWatcher] Root Folders: {RootFolders}", rootFolder); if (!rootFolder.Any()) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. diff --git a/UI/Web/src/_manga-reader-common.scss b/UI/Web/src/_manga-reader-common.scss new file mode 100644 index 000000000..e6ff3aac8 --- /dev/null +++ b/UI/Web/src/_manga-reader-common.scss @@ -0,0 +1,73 @@ +img { + user-select: none; +} + +.image-container { + text-align: center; + align-items: center; + + &.full-width { + width: 100vw; + height: calc(var(--vh)*100); + display: grid; + } + + &.full-height { + height: 100vh; + display: inline-block; + } + + &.original { + height: 100vh; + display: grid; + } + + .full-height { + width: auto; + margin: 0 auto; + max-height: calc(var(--vh)*100); + vertical-align: top; + &.wide { + height: 100vh; + } + } + + .original { + align-self: center; + width: auto; + margin: 0 auto; + vertical-align: top; + } + + .full-width { + width: 100%; + margin: 0 auto; + vertical-align: top; + max-width: fit-content; + } +} + + +.bookmark-effect { + animation: bookmark 0.7s cubic-bezier(0.165, 0.84, 0.44, 1); +} + +// TODO: Move this into a dedicated component +.loading { + position: absolute; + left: 48%; + top: 20%; + z-index: 1; +} + + +.highlight { + background-color: var(--manga-reader-next-highlight-bg-color) !important; + animation: fadein .5s both; + backdrop-filter: blur(10px); +} +.highlight-2 { + background-color: var(--manga-reader-prev-highlight-bg-color) !important; + animation: fadein .5s both; + backdrop-filter: blur(10px); +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/readers/page-layout-mode.ts b/UI/Web/src/app/_models/page-layout-mode.ts similarity index 100% rename from UI/Web/src/app/_models/readers/page-layout-mode.ts rename to UI/Web/src/app/_models/page-layout-mode.ts diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 680386e48..674dd88ca 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,7 +1,7 @@ import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; import { BookPageLayoutMode } from '../readers/book-page-layout-mode'; -import { PageLayoutMode } from '../readers/page-layout-mode'; +import { PageLayoutMode } from '../page-layout-mode'; import { PageSplitOption } from './page-split-option'; import { ReaderMode } from './reader-mode'; import { ReadingDirection } from './reading-direction'; diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index f6a83e8bd..cc0167393 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -1,10 +1,10 @@ import { Component, Input, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { SelectionModel } from 'src/app/typeahead/typeahead.component'; import { Library } from 'src/app/_models/library'; import { Member } from 'src/app/_models/auth/member'; import { LibraryService } from 'src/app/_services/library.service'; +import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component'; @Component({ selector: 'app-library-access-modal', diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.ts b/UI/Web/src/app/admin/library-selector/library-selector.component.ts index 05b4f3bdb..55650f332 100644 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.ts +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.ts @@ -1,9 +1,9 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormBuilder } from '@angular/forms'; -import { SelectionModel } from 'src/app/typeahead/typeahead.component'; import { Library } from 'src/app/_models/library'; import { Member } from 'src/app/_models/auth/member'; import { LibraryService } from 'src/app/_services/library.service'; +import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component'; @Component({ selector: 'app-library-selector', diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 8b834a2ff..2738346e7 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -1,36 +1,36 @@ @font-face { font-family: "Fira_Sans"; - src: url(../../../assets/fonts/Fira_Sans/FiraSans-Regular.ttf) format("truetype"); + src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.ttf) format("truetype"); } @font-face { font-family: "Lato"; - src: url(../../../assets/fonts/Lato/Lato-Regular.ttf) format("truetype"); + src: url(../../../../assets/fonts/Lato/Lato-Regular.ttf) format("truetype"); } @font-face { font-family: "Libre_Baskerville"; - src: url(../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.ttf) format("truetype"); + src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.ttf) format("truetype"); } @font-face { font-family: "Merriweather"; - src: url(../../../assets/fonts/Merriweather/Merriweather-Regular.ttf) format("truetype"); + src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.ttf) format("truetype"); } @font-face { font-family: "Nanum_Gothic"; - src: url(../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.ttf) format("truetype"); + src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.ttf) format("truetype"); } @font-face { font-family: "RocknRoll_One"; - src: url(../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype"); + src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype"); } @font-face { font-family: "OpenDyslexic2"; - src: url(../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf) format("opentype"); + src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf) format("opentype"); } :root { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 88d69d344..88e445e90 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -1254,9 +1254,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { updateReadingSectionHeight() { setTimeout(() => { if (this.immersiveMode) { - this.renderer.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100)', RendererStyleFlags2.Important); + this.renderer?.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100)', RendererStyleFlags2.Important); } else { - this.renderer.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); + this.renderer?.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); } }); } diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts index b94427051..7d0b9ee6a 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts @@ -5,7 +5,7 @@ import { ToastrService } from 'ngx-toastr'; import { debounceTime, distinctUntilChanged, forkJoin, Subject, switchMap, takeUntil, tap } from 'rxjs'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; -import { SelectionModel } from 'src/app/typeahead/typeahead.component'; +import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html new file mode 100644 index 000000000..f34bec0a2 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html @@ -0,0 +1,6 @@ +
+ +
+ diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.scss b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.scss new file mode 100644 index 000000000..1443e0e15 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.scss @@ -0,0 +1,25 @@ +@use '../../../../manga-reader-common'; + +.full-height { + width: auto; + margin: 0 auto; + max-height: calc(var(--vh)*100); + vertical-align: top; + &.wide { + height: 100vh; + } + } + + .original { + align-self: center; + width: auto; + margin: 0 auto; + vertical-align: top; + } + + .full-width { + width: 100%; + margin: 0 auto; + vertical-align: top; + max-width: fit-content; + } diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts new file mode 100644 index 000000000..fbe731238 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts @@ -0,0 +1,249 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { map, Observable, of, Subject, takeUntil, tap } from 'rxjs'; +import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; +import { LayoutMode } from '../../_models/layout-mode'; +import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from '../../_models/reader-enums'; +import { ReaderSetting } from '../../_models/reader-setting'; +import { ImageRenderer } from '../../_models/renderer'; +import { ManagaReaderService } from '../../_series/managa-reader.service'; + +@Component({ + selector: 'app-canvas-renderer', + templateUrl: './canvas-renderer.component.html', + styleUrls: ['./canvas-renderer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CanvasRendererComponent implements OnInit, AfterViewInit, OnDestroy, ImageRenderer { + + @Input() readerSettings$!: Observable; + @Input() image$!: Observable; + @Input() bookmark$!: Observable; + @Input() showClickOverlay$!: Observable; + @Input() imageFit$!: Observable; + @Output() imageHeight: EventEmitter = new EventEmitter(); + + @ViewChild('content') canvas: ElementRef | undefined; + private ctx!: CanvasRenderingContext2D; + private readonly onDestroy = new Subject(); + + currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT; + pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; + + fit: FITTING_OPTION = FITTING_OPTION.ORIGINAL; + pageSplit: PageSplitOption = PageSplitOption.FitSplit; + layoutMode: LayoutMode = LayoutMode.Single; + + canvasImage: HTMLImageElement | null = null; + showClickOverlayClass$!: Observable; + /** + * Maps darkness value to the filter style + */ + darkenss$: Observable = of('brightness(100%)'); + /** + * Maps image fit value to the classes for image fitting + */ + imageFitClass$!: Observable; + renderWithCanvas: boolean = false; + + + + constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: ManagaReaderService) { } + + ngOnInit(): void { + this.readerSettings$.pipe(takeUntil(this.onDestroy), tap(value => { + this.fit = value.fitting; + this.pageSplit = value.pageSplit; + this.layoutMode = value.layoutMode; + const rerenderNeeded = this.pageSplit != value.pageSplit; + this.pagingDirection = value.pagingDirection; + if (rerenderNeeded) { + this.reset(); + } + })).subscribe(() => {}); + + this.darkenss$ = this.readerSettings$.pipe( + map(values => 'brightness(' + values.darkness + '%)'), + takeUntil(this.onDestroy) + ); + + this.imageFitClass$ = this.readerSettings$.pipe( + takeUntil(this.onDestroy), + map(values => values.fitting), + map(fit => { + if (fit === FITTING_OPTION.WIDTH || this.layoutMode === LayoutMode.Single) return fit; + if (this.canvasImage === null) return fit; + + // Would this ever execute given that we perform splitting only in this renderer? + if ( + this.mangaReaderService.isWideImage(this.canvasImage) && + this.mangaReaderService.shouldRenderAsFitSplit(this.pageSplit) + ) { + // Rewriting to fit to width for this cover image + console.log('Fit (override): ', fit); + return FITTING_OPTION.WIDTH; + } + return fit; + }) + ); + + + this.bookmark$.pipe( + takeUntil(this.onDestroy), + tap(_ => { + if (this.currentImageSplitPart === SPLIT_PAGE_PART.NO_SPLIT) return; + if (!this.canvas) return; + + const elements = [this.canvas?.nativeElement]; + console.log('Applying bookmark on ', elements); + this.mangaReaderService.applyBookmarkEffect(elements); + }) + ).subscribe(() => {}); + + this.showClickOverlayClass$ = this.showClickOverlay$.pipe( + map(showOverlay => showOverlay ? 'blur' : ''), + takeUntil(this.onDestroy) + ); + } + + ngAfterViewInit() { + if (this.canvas) { + this.ctx = this.canvas.nativeElement.getContext('2d', { alpha: false }); + } + } + + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + reset() { + this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT; + } + + updateSplitPage() { + if (this.canvasImage == null) return; + const needsSplitting = this.mangaReaderService.isWideImage(this.canvasImage); + if (!needsSplitting || this.mangaReaderService.isNoSplit(this.pageSplit)) { + this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT; + return needsSplitting; + } + const splitLeftToRight = this.mangaReaderService.isSplitLeftToRight(this.pageSplit); + + if (this.pagingDirection === PAGING_DIRECTION.FORWARD) { + switch (this.currentImageSplitPart) { + case SPLIT_PAGE_PART.NO_SPLIT: + this.currentImageSplitPart = splitLeftToRight ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.RIGHT_PART; + break; + case SPLIT_PAGE_PART.LEFT_PART: + const r2lSplittingPart = (needsSplitting ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.NO_SPLIT); + this.currentImageSplitPart = splitLeftToRight ? SPLIT_PAGE_PART.RIGHT_PART : r2lSplittingPart; + break; + case SPLIT_PAGE_PART.RIGHT_PART: + const l2rSplittingPart = (needsSplitting ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.NO_SPLIT); + this.currentImageSplitPart = splitLeftToRight ? l2rSplittingPart : SPLIT_PAGE_PART.LEFT_PART; + break; + } + } else if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { + switch (this.currentImageSplitPart) { + case SPLIT_PAGE_PART.NO_SPLIT: + this.currentImageSplitPart = splitLeftToRight ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.LEFT_PART; + break; + case SPLIT_PAGE_PART.LEFT_PART: + const l2rSplittingPart = (needsSplitting ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.NO_SPLIT); + this.currentImageSplitPart = splitLeftToRight? l2rSplittingPart : SPLIT_PAGE_PART.RIGHT_PART; + break; + case SPLIT_PAGE_PART.RIGHT_PART: + this.currentImageSplitPart = splitLeftToRight ? SPLIT_PAGE_PART.LEFT_PART : (needsSplitting ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.NO_SPLIT); + break; + } + } + return needsSplitting; + } + + /** + * This renderer does not render when splitting is not needed + * @param img + * @returns + */ + renderPage(img: Array) { + this.renderWithCanvas = false; + if (img === null || img.length === 0 || img[0] === null) return; + if (!this.ctx || !this.canvas) return; + this.canvasImage = img[0]; + this.cdRef.markForCheck(); + + const needsSplitting = this.updateSplitPage(); + //console.log('split: ',this.currentImageSplitPart); + if (!needsSplitting) return; + if (this.currentImageSplitPart === SPLIT_PAGE_PART.NO_SPLIT) return; + + this.renderWithCanvas = true; + this.setCanvasSize(); + + if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) { + this.canvas.nativeElement.width = this.canvasImage.width / 2; + this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, 0, 0, this.canvasImage.width, this.canvasImage.height); + this.cdRef.markForCheck(); + } else if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.RIGHT_PART) { + this.canvas.nativeElement.width = this.canvasImage.width / 2; + this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height); + this.cdRef.markForCheck(); + } + + this.cdRef.markForCheck(); + } + + getPageAmount(direction: PAGING_DIRECTION) { + if (this.canvasImage === null) return 1; + if (!this.mangaReaderService.isWideImage(this.canvasImage)) return 1; + switch(direction) { + case PAGING_DIRECTION.FORWARD: + return this.shouldMoveNext() ? 1 : 0; + case PAGING_DIRECTION.BACKWARDS: + return this.shouldMovePrev() ? 1 : 0; + } + } + + shouldMoveNext() { + if (this.mangaReaderService.isNoSplit(this.pageSplit)) return true; + return this.currentImageSplitPart !== (this.mangaReaderService.isSplitLeftToRight(this.pageSplit) ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.RIGHT_PART); + } + + shouldMovePrev() { + if (this.mangaReaderService.isNoSplit(this.pageSplit)) return true; + return this.currentImageSplitPart !== (this.mangaReaderService.isSplitLeftToRight(this.pageSplit) ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.LEFT_PART); + } + + /** + * There are some hard limits on the size of canvas' that we must cap at. https://github.com/jhildenbiddle/canvas-size#test-results + * For Safari, it's 16,777,216, so we cap at 4096x4096 when this happens. The drawImage in render will perform bi-cubic scaling for us. + */ + setCanvasSize() { + if (this.canvasImage == null) return; + if (!this.ctx || !this.canvas) { return; } + // TODO: Move this somewhere else (maybe canvas renderer?) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const isSafari = [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod' + ].includes(navigator.platform) + // iPad on iOS 13 detection + || (navigator.userAgent.includes("Mac") && "ontouchend" in document); + const canvasLimit = isSafari ? 16_777_216 : 124_992_400; + const needsScaling = this.canvasImage.width * this.canvasImage.height > canvasLimit; + if (needsScaling) { + this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384; + this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384; + } else { + this.canvas.nativeElement.width = this.canvasImage.width; + this.canvas.nativeElement.height = this.canvasImage.height; + } + this.imageHeight.emit(this.canvas.nativeElement.height); + this.cdRef.markForCheck(); + } +} diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html new file mode 100644 index 000000000..f3611c3f2 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html @@ -0,0 +1,18 @@ + +
+ +  + + +  + +
+
\ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.scss b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.scss new file mode 100644 index 000000000..919a0ab00 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.scss @@ -0,0 +1,45 @@ +@use '../../../../manga-reader-common'; + +.image-container { + #image-1 { + &.double { + margin: 0 0 0 auto; + } + } +} + +.full-width { + width: 100%; + margin: 0 auto; + vertical-align: top; + max-width: fit-content; + + &.double { + width: 50%; + + &.cover { + width: 100%; + } + } +} + +.center-double { + display: flex; + overflow: unset; +} + +.fit-to-width-double-offset { + max-width: 100%; // max-width fixes center alignment issue +} + +.original-double-offset { + max-width: 100%; +} + +.fit-to-height-double-offset { + height: 100vh; + object-fit: scale-down; + top: 50%; + left: 50%; + max-width: 100%; +} diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts new file mode 100644 index 000000000..0a087e47e --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts @@ -0,0 +1,324 @@ +import { DOCUMENT } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Observable, of, Subject, map, takeUntil, tap, zip, shareReplay, filter } from 'rxjs'; +import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; +import { ReaderMode } from 'src/app/_models/preferences/reader-mode'; +import { ReaderService } from 'src/app/_services/reader.service'; +import { LayoutMode } from '../../_models/layout-mode'; +import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; +import { ReaderSetting } from '../../_models/reader-setting'; +import { ImageRenderer } from '../../_models/renderer'; +import { ManagaReaderService } from '../../_series/managa-reader.service'; + +@Component({ + selector: 'app-double-renderer', + templateUrl: './double-renderer.component.html', + styleUrls: ['./double-renderer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DoubleRendererComponent implements OnInit, OnDestroy, ImageRenderer { + + @Input() readerSettings$!: Observable; + @Input() image$!: Observable; + /** + * The image fit class + */ + @Input() imageFit$!: Observable; + @Input() bookmark$!: Observable; + @Input() showClickOverlay$!: Observable; + @Input() pageNum$!: Observable<{pageNum: number, maxPages: number}>; + + @Input() getPage!: (pageNum: number) => HTMLImageElement; + + @Output() imageHeight: EventEmitter = new EventEmitter(); + + imageFitClass$!: Observable; + showClickOverlayClass$!: Observable; + readerModeClass$!: Observable; + layoutClass$!: Observable; + shouldRenderSecondPage$!: Observable; + darkenss$: Observable = of('brightness(100%)'); + layoutMode: LayoutMode = LayoutMode.Single; + pageSplit: PageSplitOption = PageSplitOption.FitSplit; + pageNum: number = 0; + maxPages: number = 0; + + /** + * Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer. + * @remarks Used for rendering to screen. + */ + currentImage = new Image(); + /** + * Used solely for LayoutMode.Double rendering. + * @remarks Used for rendering to screen. + */ + currentImage2 = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the previous image to currentImage + * @see currentImage + */ + currentImagePrev = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the next image to currentImage + * @see currentImage + */ + currentImageNext = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current - 2 image to currentImage + * @see currentImage + */ + currentImage2Behind = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current + 2 image to currentImage + * @see currentImage + */ + currentImage2Ahead = new Image(); + + /** + * Determines if we should render a double page. + * The general gist is if we are on double layout mode, the current page (first page) is not a cover image or a wide image + * and the next page is not a wide image (as only non-wides should be shown next to each other). + * @remarks This will always fail if the window's width is greater than the height + */ + shouldRenderDouble$!: Observable; + + private readonly onDestroy = new Subject(); + + get ReaderMode() {return ReaderMode;} + get FITTING_OPTION() {return FITTING_OPTION;} + get LayoutMode() {return LayoutMode;} + + + + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } + + ngOnInit(): void { + this.readerModeClass$ = this.readerSettings$.pipe( + filter(_ => this.isValid()), + map(values => values.readerMode), + map(mode => mode === ReaderMode.LeftRight || mode === ReaderMode.UpDown ? '' : 'd-none'), + takeUntil(this.onDestroy) + ); + + this.darkenss$ = this.readerSettings$.pipe( + map(values => 'brightness(' + values.darkness + '%)'), + filter(_ => this.isValid()), + takeUntil(this.onDestroy) + ); + + this.showClickOverlayClass$ = this.showClickOverlay$.pipe( + map(showOverlay => showOverlay ? 'blur' : ''), + filter(_ => this.isValid()), + takeUntil(this.onDestroy) + ); + + this.pageNum$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + tap(pageInfo => { + this.pageNum = pageInfo.pageNum; + this.maxPages = pageInfo.maxPages; + + this.currentImage = this.getPage(this.pageNum); + this.currentImage2 = this.getPage(this.pageNum + 1); + + this.currentImageNext = this.getPage(this.pageNum + 1); + this.currentImagePrev = this.getPage(this.pageNum - 1); + + this.currentImage2Behind = this.getPage(this.pageNum - 2); + this.currentImage2Ahead = this.getPage(this.pageNum + 2); + this.cdRef.markForCheck(); + })).subscribe(() => {}); + + this.shouldRenderDouble$ = this.pageNum$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map((_) => { + return this.shouldRenderDouble(); + }) + ); + + this.layoutClass$ = zip(this.shouldRenderDouble$, this.imageFit$).pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map((value) => { + if (!value[0]) return 'd-none'; + if (value[0] && value[1] === FITTING_OPTION.WIDTH) return 'fit-to-width-double-offset'; + if (value[0] && value[1] === FITTING_OPTION.HEIGHT) return 'fit-to-height-double-offset'; + if (value[0] && value[1] === FITTING_OPTION.ORIGINAL) return 'original-double-offset'; + return ''; + }) + ); + + this.shouldRenderSecondPage$ = this.pageNum$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map(_ => { + if (this.currentImage2.src === '') { + console.log('Not rendering second page as 2nd image is empty'); + return false; + } + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Not rendering second page as on cover image'); + return false; + } + if (this.readerService.imageUrlToPageNum(this.currentImage2.src) > this.maxPages - 1) { + console.log('Not rendering second page as 2nd image is on last page'); + return false; + } + if (this.mangaReaderService.isWideImage(this.currentImageNext)) { + console.log('Not rendering second page as next page is wide'); + return false; + } + + if (this.mangaReaderService.isWideImage(this.currentImage)) { + console.log('Not rendering second page as next page is wide'); + return false; + } + + if (this.mangaReaderService.isWideImage(this.currentImagePrev)) { + console.log('Not rendering second page as prev page is wide'); + return false; + } + return true; + }) + ); + + this.readerSettings$.pipe( + takeUntil(this.onDestroy), + tap(values => { + this.layoutMode = values.layoutMode; + this.pageSplit = values.pageSplit; + this.cdRef.markForCheck(); + }) + ).subscribe(() => {}); + + this.bookmark$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + tap(_ => { + const elements = []; + const image1 = this.document.querySelector('#image-1'); + if (image1 != null) elements.push(image1); + + const image2 = this.document.querySelector('#image-2'); + if (image2 != null) elements.push(image2); + + this.mangaReaderService.applyBookmarkEffect(elements); + }) + ).subscribe(() => {}); + + + this.imageFitClass$ = this.readerSettings$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map(values => values.fitting), + shareReplay() + ); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + shouldRenderDouble() { + if (this.layoutMode !== LayoutMode.Double) return false; + + return !( + this.mangaReaderService.isCoverImage(this.pageNum) + || this.mangaReaderService.isWideImage(this.currentImage) + || this.mangaReaderService.isWideImage(this.currentImageNext) + ); + } + + isValid() { + return this.layoutMode === LayoutMode.Double; + } + + renderPage(img: Array): void { + if (img === null || img.length === 0 || img[0] === null) return; + if (!this.isValid()) return; + + console.log('[DoubleRenderer] renderPage(): ', this.pageNum); + console.log(this.readerService.imageUrlToPageNum(this.currentImage2Behind.src), this.readerService.imageUrlToPageNum(this.currentImagePrev.src), + '[', this.readerService.imageUrlToPageNum(this.currentImage.src), ']', + this.readerService.imageUrlToPageNum(this.currentImageNext.src), this.readerService.imageUrlToPageNum(this.currentImage2Ahead.src)) + + + if (!this.shouldRenderDouble()) { + this.imageHeight.emit(this.currentImage.height); + return; + } + + this.currentImage2 = this.currentImageNext; + + this.cdRef.markForCheck(); + this.imageHeight.emit(Math.max(this.currentImage.height, this.currentImage2.height)); + this.cdRef.markForCheck(); + } + + shouldMovePrev(): boolean { + return true; + } + shouldMoveNext(): boolean { + return true; + } + getPageAmount(direction: PAGING_DIRECTION): number { + if (this.layoutMode !== LayoutMode.Double) return 0; + + // If prev page: + switch (direction) { + case PAGING_DIRECTION.FORWARD: + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Moving forward 1 page as on cover image'); + return 1; + } + if (this.mangaReaderService.isWideImage(this.currentImage)) { + console.log('Moving forward 1 page as current page is wide'); + return 1; + } + if (this.mangaReaderService.isWideImage(this.currentImageNext)) { + console.log('Moving forward 1 page as next page is wide'); + return 1; + } + if (this.mangaReaderService.isSecondLastImage(this.pageNum, this.maxPages)) { + console.log('Moving forward 1 page as 2 pages left'); + return 1; + } + if (this.mangaReaderService.isLastImage(this.pageNum, this.maxPages)) { + console.log('Moving forward 1 page as 1 page left'); + return 1; + } + console.log('Moving forward 2 pages'); + return 2; + case PAGING_DIRECTION.BACKWARDS: + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Moving back 1 page as on cover image'); + return 1; + } + if (this.mangaReaderService.isWideImage(this.currentImage)) { + console.log('Moving back 1 page as current page is wide'); + return 1; + } + if (this.mangaReaderService.isWideImage(this.currentImagePrev)) { + console.log('Moving back 1 page as prev page is wide'); + return 1; + } + if (this.mangaReaderService.isWideImage(this.currentImage2Behind)) { + console.log('Moving back 1 page as 2 pages back is wide'); + return 1; + } + // Not sure about this condition on moving backwards + if (this.mangaReaderService.isSecondLastImage(this.pageNum, this.maxPages)) { + console.log('Moving back 1 page as 2 pages left'); + return 1; + } + console.log('Moving back 2 pages'); + return 2; + } + } + reset(): void {} + +} diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html new file mode 100644 index 000000000..c1874dd9f --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html @@ -0,0 +1,18 @@ + +
+ +  + + +  + +
+
\ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss new file mode 100644 index 000000000..537d60286 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss @@ -0,0 +1,64 @@ +@use '../../../../manga-reader-common'; + +// Overrides for reverse +.image-container { + &.reverse { + overflow: unset; + display: flex; + align-content: center; + justify-content: center; + flex-direction: row-reverse; + + img { + margin: unset; + } + } + + #image-1 { + &.double { + margin: 0 0 0 auto; + } + } + + #image-2 { + &.double { + margin: 0 auto 0 0; + } + } +} + +.full-width { + width: 100%; + margin: 0 auto; + vertical-align: top; + max-width: fit-content; + + &.double { + width: 50%; + + &.cover { + width: 100%; + } + } +} + +.center-double { + display: flex; + overflow: unset; +} + +.fit-to-width-double-offset { + max-width: 100%; // max-width fixes center alignment issue +} + +.original-double-offset { + max-width: 100%; +} + +.fit-to-height-double-offset { + height: 100vh; + object-fit: scale-down; + top: 50%; + left: 50%; + max-width: 100%; +} diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts new file mode 100644 index 000000000..feda43ada --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts @@ -0,0 +1,499 @@ +import { DOCUMENT } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Observable, of, Subject, map, takeUntil, tap, zip, shareReplay, filter } from 'rxjs'; +import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; +import { ReaderMode } from 'src/app/_models/preferences/reader-mode'; +import { ReaderService } from 'src/app/_services/reader.service'; +import { LayoutMode } from '../../_models/layout-mode'; +import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; +import { ReaderSetting } from '../../_models/reader-setting'; +import { ImageRenderer } from '../../_models/renderer'; +import { ManagaReaderService } from '../../_series/managa-reader.service'; + +/** + * This is aimed at manga. Double page renderer but where if we have page = 10, you will see + * page 11 page 10. + */ +@Component({ + selector: 'app-double-reverse-renderer', + templateUrl: './double-reverse-renderer.component.html', + styleUrls: ['./double-reverse-renderer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DoubleReverseRendererComponent implements OnInit, OnDestroy, ImageRenderer { + + + @Input() readerSettings$!: Observable; + @Input() image$!: Observable; + /** + * The image fit class + */ + @Input() imageFit$!: Observable; + @Input() bookmark$!: Observable; + @Input() showClickOverlay$!: Observable; + @Input() pageNum$!: Observable<{pageNum: number, maxPages: number}>; + + @Input() getPage!: (pageNum: number) => HTMLImageElement; + + @Output() imageHeight: EventEmitter = new EventEmitter(); + + imageFitClass$!: Observable; + showClickOverlayClass$!: Observable; + readerModeClass$!: Observable; + layoutClass$!: Observable; + shouldRenderSecondPage$!: Observable; + darkenss$: Observable = of('brightness(100%)'); + layoutMode: LayoutMode = LayoutMode.Single; + pageSplit: PageSplitOption = PageSplitOption.FitSplit; + pageNum: number = 0; + maxPages: number = 0; + + /** + * Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer. + * @remarks Used for rendering to screen. + */ + leftImage = new Image(); + /** + * Used solely for LayoutMode.Double rendering. + * @remarks Used for rendering to screen. + */ + rightImage = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the previous image to currentImage + * @see currentImage + */ + currentImagePrev = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the next image to currentImage + * @see currentImage + */ + currentImageNext = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current - 2 image to currentImage + * @see currentImage + */ + currentImage2Behind = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current + 2 image to currentImage + * @see currentImage + */ + currentImage2Ahead = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current - 3 image to currentImage + * @see currentImage + */ + currentImage3Behind = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current + 3 image to currentImage + * @see currentImage + */ + currentImage3Ahead = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current - 4 image to currentImage + * @see currentImage + */ + currentImage4Behind = new Image(); + /** + * Used solely for LayoutMode.Double rendering. Will always hold the current + 4 image to currentImage + * @see currentImage + */ + currentImage4Ahead = new Image(); + + /** + * Determines if we should render a double page. + * The general gist is if we are on double layout mode, the current page (first page) is not a cover image or a wide image + * and the next page is not a wide image (as only non-wides should be shown next to each other). + * @remarks This will always fail if the window's width is greater than the height + */ + shouldRenderDouble$!: Observable; + + pageSpreadMap: {[key: number]: 'W'|'S'} = {}; + + private readonly onDestroy = new Subject(); + + get ReaderMode() {return ReaderMode;} + get FITTING_OPTION() {return FITTING_OPTION;} + get LayoutMode() {return LayoutMode;} + + + + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } + + ngOnInit(): void { + this.readerModeClass$ = this.readerSettings$.pipe( + filter(_ => this.isValid()), + map(values => values.readerMode), + map(mode => mode === ReaderMode.LeftRight || mode === ReaderMode.UpDown ? '' : 'd-none'), + takeUntil(this.onDestroy) + ); + + this.darkenss$ = this.readerSettings$.pipe( + filter(_ => this.isValid()), + map(values => 'brightness(' + values.darkness + '%)'), + takeUntil(this.onDestroy) + ); + + this.showClickOverlayClass$ = this.showClickOverlay$.pipe( + filter(_ => this.isValid()), + map(showOverlay => showOverlay ? 'blur' : ''), + takeUntil(this.onDestroy) + ); + + this.pageNum$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + tap(pageInfo => { + this.pageNum = pageInfo.pageNum; + this.maxPages = pageInfo.maxPages; + + this.leftImage = this.getPage(this.pageNum); + this.rightImage = this.getPage(this.pageNum + 1); + + this.currentImageNext = this.getPage(this.pageNum + 1); + this.currentImagePrev = this.getPage(this.pageNum - 1); + + this.currentImage2Behind = this.getPage(this.pageNum - 2); + this.currentImage2Ahead = this.getPage(this.pageNum + 2); + + this.currentImage3Behind = this.getPage(this.pageNum - 3); + this.currentImage3Ahead = this.getPage(this.pageNum + 3); + + this.currentImage4Behind = this.getPage(this.pageNum - 4); + this.currentImage4Ahead = this.getPage(this.pageNum + 4); + + this.leftImage.addEventListener('load', () => { + this.updatePageMap(this.leftImage) + }); + this.rightImage.addEventListener('load', () => { + this.updatePageMap(this.rightImage) + }); + this.currentImageNext.addEventListener('load', () => { + this.updatePageMap(this.currentImageNext) + }); + this.currentImagePrev.addEventListener('load', () => { + this.updatePageMap(this.currentImagePrev) + }); + this.currentImage2Behind.addEventListener('load', () => { + this.updatePageMap(this.currentImage2Behind) + }); + this.currentImage2Ahead.addEventListener('load', () => { + this.updatePageMap(this.currentImage2Ahead) + }); + this.currentImage3Behind.addEventListener('load', () => { + this.updatePageMap(this.currentImage3Behind) + }); + this.currentImage3Ahead.addEventListener('load', () => { + this.updatePageMap(this.currentImage3Ahead) + }); + this.currentImage4Behind.addEventListener('load', () => { + this.updatePageMap(this.currentImage4Behind) + }); + this.currentImage4Ahead.addEventListener('load', () => { + this.updatePageMap(this.currentImage4Ahead) + }); + })).subscribe(() => {}); + + this.shouldRenderDouble$ = this.pageNum$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map((_) => this.shouldRenderDouble()), + shareReplay() + ); + + this.layoutClass$ = zip(this.shouldRenderDouble$, this.imageFit$).pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map((value) => { + if (!value[0]) return 'd-none'; + if (value[0] && value[1] === FITTING_OPTION.WIDTH) return 'fit-to-width-double-offset'; + if (value[0] && value[1] === FITTING_OPTION.HEIGHT) return 'fit-to-height-double-offset'; + if (value[0] && value[1] === FITTING_OPTION.ORIGINAL) return 'original-double-offset'; + return ''; + }) + ); + + this.shouldRenderSecondPage$ = this.pageNum$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map(_ => { + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Not rendering second page as on cover image'); + return false; + } + if (this.readerService.imageUrlToPageNum(this.rightImage.src) > this.maxPages - 1) { + console.log('Not rendering second page as 2nd image is on last page'); + return false; + } + if (this.isWide(this.leftImage)) { + console.log('Not rendering second page as right page is wide'); + return false; + } + if (this.isWide(this.rightImage)) { + console.log('Not rendering second page as right page is wide'); + return false; + } + if (this.isWide(this.currentImageNext)) { + console.log('Not rendering second page as next page is wide'); + return false; + } + if (this.isWide(this.currentImagePrev) && (this.isWide(this.currentImage3Ahead))) { + console.log('Not rendering second page as prev page is wide'); + return false; + } + return true; + }), + ); + + this.readerSettings$.pipe( + takeUntil(this.onDestroy), + tap(values => { + this.layoutMode = values.layoutMode; + this.pageSplit = values.pageSplit; + this.cdRef.markForCheck(); + }) + ).subscribe(() => {}); + + this.bookmark$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + tap(_ => { + const elements = []; + const image1 = this.document.querySelector('#image-1'); + if (image1 != null) elements.push(image1); + + const image2 = this.document.querySelector('#image-2'); + if (image2 != null) elements.push(image2); + + this.mangaReaderService.applyBookmarkEffect(elements); + }) + ).subscribe(() => {}); + + + this.imageFitClass$ = this.readerSettings$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map(values => values.fitting), + shareReplay() + ); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + updatePageMap(img: HTMLImageElement) { + const page = this.readerService.imageUrlToPageNum(img.src); + if (!this.pageSpreadMap.hasOwnProperty(page)) { + this.pageSpreadMap[page] = this.mangaReaderService.isWideImage(img) ? 'W' : 'S'; + } + } + + /** + * We should Render 2 pages if: + * 1. We are not currently the first image (cover image) + * 2. The previous page is not a cover image + * 3. The current page is not a wide image + * 4. The next page is not a wide image + */ + shouldRenderDouble() { + if (!this.isValid()) return false; + + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Not rendering right image as is cover image'); + return false; + } + if (this.mangaReaderService.isCoverImage(this.pageNum + 1)) { + console.log('Not rendering right image as current - 1 is cover image'); + return false; + } + if (this.isWide(this.leftImage)) { + console.log('Not rendering right image as left is wide'); + //return false; + } + if (this.isWide(this.rightImage)) { + console.log('Not rendering right image as it is wide'); + return false; + } + + if (this.isWide(this.currentImageNext)) { + console.log('Not rendering right image as it is wide'); + return false; + } + + + return true; + + + // const result = !( + // this.mangaReaderService.isCoverImage(this.pageNum) + // || this.mangaReaderService.isCoverImage(this.pageNum - 1) // This is because we use prev page and hence the cover will re-show + // || this.mangaReaderService.isWideImage(this.leftImage) + // || this.mangaReaderService.isWideImage(this.currentImageNext) + // ); + + // return result; + } + + isWide(img: HTMLImageElement) { + const page = this.readerService.imageUrlToPageNum(img.src); + return this.mangaReaderService.isWideImage(img) || this.pageSpreadMap.hasOwnProperty(page) && this.pageSpreadMap[page] === 'W'; + } + + isValid() { + return this.layoutMode === LayoutMode.DoubleReversed; + } + + renderPage(img: Array): void { + if (img === null || img.length === 0 || img[0] === null) return; + if (!this.isValid()) return; + + console.log('[DoubleRenderer] renderPage(): ', this.pageNum); + + const allImages = [ + this.currentImage4Behind, this.currentImage3Behind, this.currentImage2Behind, this.currentImagePrev, + this.leftImage, + this.currentImageNext, this.currentImage2Ahead, this.currentImage3Ahead, this.currentImage4Ahead + ]; + + console.log('DoubleRenderer buffered pages: ', allImages.map(img => { + const page = this.readerService.imageUrlToPageNum(img.src); + if (page === this.pageNum) return '[' + page + ']'; + return page; + }).join(', ')); + + + this.rightImage = this.currentImageNext; + + + this.cdRef.markForCheck(); + this.imageHeight.emit(Math.max(this.leftImage.height, this.rightImage.height)); + this.cdRef.markForCheck(); + } + + shouldMovePrev(): boolean { + return true; + } + shouldMoveNext(): boolean { + return true; + } + getPageAmount(direction: PAGING_DIRECTION): number { + if (this.layoutMode !== LayoutMode.DoubleReversed) return 0; + // console.log("----currentImage4Behind:", this.currentImage4Behind); + // console.log("---currentImage3Behind:", this.currentImage3Behind); + // console.log("--currentImage2Behind:", this.currentImage2Behind); + // console.log("-currentImagePrev:", this.currentImagePrev); + // console.log("leftImage", this.leftImage); + // console.log("rightImage", this.rightImage); + // console.log("+currentImageNext:", this.currentImageNext); + // console.log("++currentImage2Ahead:", this.currentImage2Ahead); + // console.log("+++currentImage3Ahead:", this.currentImage3Ahead); + // console.log("++++currentImage4Ahead:", this.currentImage4Ahead); + + const allImages = [ + this.currentImage4Behind, this.currentImage3Behind, this.currentImage2Behind, this.currentImagePrev, + this.leftImage, this.rightImage, + this.currentImageNext, this.currentImage2Ahead, this.currentImage3Ahead, this.currentImage4Ahead + ]; + + console.log('[getPageAmount for double reverse]: ', allImages.map(img => { + const page = this.readerService.imageUrlToPageNum(img.src); + if (page === this.pageNum) return '[' + page; + if (page === this.pageNum + 1) return page + ']'; + return page + ''; + })); + console.log("Current Page: ", this.pageNum); + console.log("Total Pages: ", this.maxPages); + + switch (direction) { + case PAGING_DIRECTION.FORWARD: + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Moving forward 1 page as on cover image'); + return 1; + } + + if (this.mangaReaderService.isSecondLastImage(this.pageNum, this.maxPages-1)) { + console.log('Moving forward 1 page as 2 pages left'); + return 1; + } + + if (this.mangaReaderService.isWideImage(this.rightImage)) { + console.log('Moving forward 1 page as current page is wide'); + return 1; + } + + if (this.mangaReaderService.isWideImage(this.leftImage)) { + console.log('Moving forward 1 page as current page is wide'); + return 1; + } + if (this.mangaReaderService.isWideImage(this.currentImageNext)) { + console.log('Moving forward 1 page as next page is wide'); + return 1; + } + + if (this.mangaReaderService.isWideImage(this.currentImagePrev)) { + console.log('Moving forward 1 page as prev page is wide'); + return 1; + } + + if (this.mangaReaderService.isLastImage(this.pageNum, this.maxPages-1)) { + console.log('Moving forward 1 page as 1 page left'); + return 1; + } + + if (this.pageNum === this.maxPages - 1) { + console.log('Moving forward 0 page as on last page'); + return 0; + } + + console.log('Moving forward 2 pages'); + return 2; + case PAGING_DIRECTION.BACKWARDS: + if (this.mangaReaderService.isCoverImage(this.pageNum)) { + console.log('Moving back 1 page as on cover image'); + return 1; + } + + if (this.isWide(this.rightImage)) { + console.log('Moving back 2 page as right page is wide'); + return 2; + } + + if (this.isWide(this.leftImage) && (!this.isWide(this.currentImage4Behind))) { + console.log('Moving back 1 page as left page is wide'); + return 1; + } + + if (this.isWide(this.currentImageNext)) { + console.log('Moving back 2 page as prev page is wide'); + return 1; + } + + if (this.isWide(this.currentImagePrev)) { + console.log('Moving back 1 page as prev page is wide'); + return 1; + } + + if (this.isWide(this.currentImage2Behind)) { + console.log('Moving back 1 page as 2 pages back is wide'); + return 1; + } + + if (this.isWide(this.currentImage2Ahead)) { + console.log('Moving back 2 page as 2 pages back is wide'); + return 1; + } + // Not sure about this condition on moving backwards + if (this.mangaReaderService.isSecondLastImage(this.pageNum, this.maxPages)) { + console.log('Moving back 1 page as 2 pages left'); + return 1; + } + console.log('Moving back 2 pages'); + return 2; + } + } + reset(): void {} + + +} diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html similarity index 100% rename from UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html rename to UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss similarity index 100% rename from UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss rename to UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts similarity index 99% rename from UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts rename to UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 4cd219cb3..27e5057b0 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -3,9 +3,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Even import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; import { ScrollService } from 'src/app/_services/scroll.service'; -import { ReaderService } from '../../_services/reader.service'; -import { PAGING_DIRECTION } from '../_models/reader-enums'; -import { WebtoonImage } from '../_models/webtoon-image'; +import { ReaderService } from '../../../_services/reader.service'; +import { PAGING_DIRECTION } from '../../_models/reader-enums'; +import { WebtoonImage } from '../../_models/webtoon-image'; /** * How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html similarity index 80% rename from UI/Web/src/app/manga-reader/manga-reader.component.html rename to UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index aca92651b..ddab7f8a0 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -26,23 +26,28 @@ - +
+ -
- - + +
+ +
+
-
@@ -61,33 +66,66 @@
-
+ + + + + + + + +
+ - -
+ + + + +  + +
-->
+ [bufferPages]="5" + [goToPage]="goToPageEvent" + (pageNumberChange)="handleWebtoonPageChange($event)" + [totalPages]="maxPages" + [urlProvider]="getPageUrl" + (loadNextChapter)="loadNextChapter()" + (loadPrevChapter)="loadPrevChapter()" + [bookmarkPage]="showBookmarkEffectEvent" + [fullscreenToggled]="fullscreenEvent"> +
@@ -95,6 +133,7 @@
+
diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.scss b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.scss similarity index 74% rename from UI/Web/src/app/manga-reader/manga-reader.component.scss rename to UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.scss index 69b0017de..f9f59601a 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.scss +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.scss @@ -4,18 +4,10 @@ $side-width: 25%; $dash-width: 3px; $pointer-offset: 5px; -img { - user-select: none; -} +@use '../../.././../manga-reader-common'; + + -@media(min-width: 600px) { - .overlay .left .i { - left: 20px; - } - .overlay .right .i { - right: 20px; - } -} .reading-area { position: relative; @@ -24,55 +16,6 @@ img { //height: calc(var(--vh)*100); // this needs to be applied on the DOM because it breaks infinite scroller } -.image-container { - text-align: center; - - // Original - //display: block; - - // New (for centering in both axis) - //display: flex; // Leave this off as it can cutoff the image - align-items: center; - - &.full-width { - width: 100vw; - height: calc(var(--vh)*100); - display: grid; - } - - &.full-height { - height: 100vh; - display: inline-block; - } - - &.original { - height: 100vh; - display: grid; - } - - #image-1 { - &.double { - margin: 0 0 0 auto; - } - } - - &.reverse { - overflow: unset; - display: flex; - align-content: center; - justify-content: center; - - img { - margin: unset; - } - } - - #image-2 { - &.double { - margin: 0 auto 0 0; - } - } -} .reader { background-color: var(--manga-reader-bg-color); @@ -83,14 +26,6 @@ img { } } - -.loading { - position: absolute; - left: 48%; - top: 20%; - z-index: 1; -} - .title, .subtitle { text-overflow: ellipsis; overflow: hidden; @@ -110,6 +45,15 @@ img { color: var(--manga-reader-overlay-text-color); } +@media(min-width: 600px) { + .overlay .left .i { + left: 20px; + } + .overlay .right .i { + right: 20px; + } +} + // Fitting Options .full-height { @@ -272,10 +216,11 @@ img { width: 100%; } - $pagination-bg: rgba(0, 0, 0, 0); - //$pagination-bg: rgba(0, 0, 255, 0.4); // DEBUG CODE + .pagination-area { + $pagination-bg: rgba(0, 0, 0, 0); + //$pagination-bg: rgba(0, 0, 255, 0.4); // DEBUG CODE cursor: pointer; z-index: 100; @@ -321,27 +266,3 @@ img { } } -.highlight { - background-color: var(--manga-reader-next-highlight-bg-color) !important; - animation: fadein .5s both; - backdrop-filter: blur(10px); -} -.highlight-2 { - background-color: var(--manga-reader-prev-highlight-bg-color) !important; - animation: fadein .5s both; - backdrop-filter: blur(10px); -} - - -.bookmark-effect { - animation: bookmark 0.7s cubic-bezier(0.165, 0.84, 0.44, 1); -} - -@keyframes bookmark { - 0%, 100% { - border: 0px; - } - 50% { - border: 5px solid var(--primary-color); - } -} diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts similarity index 68% rename from UI/Web/src/app/manga-reader/manga-reader.component.ts rename to UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index b14bb958a..070d33ff3 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -1,30 +1,36 @@ -import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, NgZone, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; -import { debounceTime, take, takeUntil } from 'rxjs/operators'; -import { User } from '../_models/user'; -import { AccountService } from '../_services/account.service'; -import { ReaderService } from '../_services/reader.service'; -import { FormBuilder, FormGroup } from '@angular/forms'; -import { NavService } from '../_services/nav.service'; -import { ReadingDirection } from '../_models/preferences/reading-direction'; -import { ScalingOption } from '../_models/preferences/scaling-option'; -import { PageSplitOption } from '../_models/preferences/page-split-option'; -import { BehaviorSubject, forkJoin, fromEvent, ReplaySubject, Subject } from 'rxjs'; -import { ToastrService } from 'ngx-toastr'; -import { Breakpoint, KEY_CODES, UtilityService } from '../shared/_services/utility.service'; -import { MemberService } from '../_services/member.service'; -import { Stack } from '../shared/data-structures/stack'; -import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider'; +import { BehaviorSubject, debounceTime, distinctUntilChanged, forkJoin, fromEvent, map, merge, Observable, ReplaySubject, Subject, take, takeUntil, tap } from 'rxjs'; +import { LabelType, ChangeContext, Options } from '@angular-slider/ngx-slider'; import { trigger, state, style, transition, animate } from '@angular/animations'; -import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums'; -import { layoutModes, pageSplitOptions, scalingOptions } from '../_models/preferences/preferences'; -import { ReaderMode } from '../_models/preferences/reader-mode'; -import { MangaFormat } from '../_models/manga-format'; -import { LibraryType } from '../_models/library'; -import { ShortcutsModalComponent } from '../reader-shared/_modals/shortcuts-modal/shortcuts-modal.component'; +import { FormGroup, FormBuilder, FormControl } from '@angular/forms'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { LayoutMode } from './_models/layout-mode'; +import { ToastrService } from 'ngx-toastr'; +import { ShortcutsModalComponent } from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component'; +import { Stack } from 'src/app/shared/data-structures/stack'; +import { Breakpoint, UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.service'; +import { LibraryType } from 'src/app/_models/library'; +import { MangaFormat } from 'src/app/_models/manga-format'; +import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; +import { scalingOptions, pageSplitOptions, layoutModes } from 'src/app/_models/preferences/preferences'; +import { ReaderMode } from 'src/app/_models/preferences/reader-mode'; +import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; +import { ScalingOption } from 'src/app/_models/preferences/scaling-option'; +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 { ReaderService } from 'src/app/_services/reader.service'; +import { LayoutMode } from '../../_models/layout-mode'; +import { PAGING_DIRECTION, FITTING_OPTION } from '../../_models/reader-enums'; +import { ReaderSetting } from '../../_models/reader-setting'; +import { ManagaReaderService } from '../../_series/managa-reader.service'; +import { CanvasRendererComponent } from '../canvas-renderer/canvas-renderer.component'; +import { DoubleRendererComponent } from '../double-renderer/double-renderer.component'; +import { DoubleReverseRendererComponent } from '../double-reverse-renderer/double-reverse-renderer.component'; +import { SingleRendererComponent } from '../single-renderer/single-renderer.component'; + const PREFETCH_PAGES = 10; @@ -42,6 +48,7 @@ const CLICK_OVERLAY_TIMEOUT = 3000; templateUrl: './manga-reader.component.html', styleUrls: ['./manga-reader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ManagaReaderService], animations: [ trigger('slideFromTop', [ state('in', style({ transform: 'translateY(0)'})), @@ -71,7 +78,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('reader') reader!: ElementRef; @ViewChild('readingArea') readingArea!: ElementRef; @ViewChild('content') canvas: ElementRef | undefined; - @ViewChild('image') image!: ElementRef; + + @ViewChild(CanvasRendererComponent, { static: false }) canvasRenderer!: CanvasRendererComponent; + @ViewChild(SingleRendererComponent, { static: false }) singleRenderer!: SingleRendererComponent; + @ViewChild(DoubleRendererComponent, { static: false }) doubleRenderer!: DoubleRendererComponent; + @ViewChild(DoubleReverseRendererComponent, { static: false }) doubleReverseRenderer!: DoubleReverseRendererComponent; libraryId!: number; @@ -111,19 +122,28 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { readingDirection = ReadingDirection.LeftToRight; scalingOption = ScalingOption.FitToHeight; pageSplitOption = PageSplitOption.FitSplit; - currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT; - pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; + isFullscreen: boolean = false; autoCloseMenu: boolean = true; + readerMode: ReaderMode = ReaderMode.LeftRight; + readerModeSubject = new BehaviorSubject(this.readerMode); + readerMode$: Observable = this.readerModeSubject.asObservable(); + + pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; + pagingDirectionSubject: Subject = new BehaviorSubject(this.pagingDirection); + pagingDirection$: Observable = this.pagingDirectionSubject.asObservable(); + pageSplitOptions = pageSplitOptions; layoutModes = layoutModes; isLoading = true; - hasBookmarkRights: boolean = false; + hasBookmarkRights: boolean = false; // TODO: This can be an observable + + getPageFn!: (pageNum: number) => HTMLImageElement; + - private ctx!: CanvasRenderingContext2D; /** * Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer. * @remarks Used for rendering to screen. @@ -164,7 +184,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * A circular array of size PREFETCH_PAGES. Maintains prefetched Images around the current page to load from to avoid loading animation. * @see CircularArray */ - cachedImages!: Array; + cachedImages: Array = []; /** * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls. * @see Stack @@ -174,12 +194,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * An event emitter when a page change occurs. Used solely by the webtoon reader. */ - goToPageEvent!: BehaviorSubject; + goToPageEvent!: BehaviorSubject; // Renderer interaction /** * An event emitter when a bookmark on a page change occurs. Used solely by the webtoon reader. */ showBookmarkEffectEvent: ReplaySubject = new ReplaySubject(); + showBookmarkEffect$: Observable = this.showBookmarkEffectEvent.asObservable(); /** * An event emitter when fullscreen mode is toggled. Used solely by the webtoon reader. */ @@ -188,10 +209,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * If the menu is open/visible. */ menuOpen = false; - /** - * Image Viewer collapsed - */ - imageViewerCollapsed = true; /** * If the prev page allows a page change to occur. */ @@ -234,6 +251,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * If the click overlay is rendered on screen */ showClickOverlay: boolean = false; + private showClickOverlaySubject: ReplaySubject = new ReplaySubject(); + showClickOverlay$ = this.showClickOverlaySubject.asObservable(); /** * Next Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking). */ @@ -288,6 +307,23 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ rightPaginationOffset = 0; + // Renderer interaction + readerSettings$!: Observable; + private currentImage: Subject = new ReplaySubject(1); + currentImage$: Observable = this.currentImage.asObservable(); + + private imageFit: Subject = new ReplaySubject(); + private imageFitClass: Subject = new ReplaySubject(); + imageFitClass$: Observable = this.imageFitClass.asObservable(); + imageFit$: Observable = this.imageFit.asObservable(); + + private imageHeight: Subject = new ReplaySubject(); + imageHeight$: Observable = this.imageHeight.asObservable(); + + private pageNumSubject: Subject<{pageNum: number, maxPages: number}> = new ReplaySubject(); + pageNum$: Observable<{pageNum: number, maxPages: number}> = this.pageNumSubject.asObservable(); + + bookmarkPageHandler = this.bookmarkPage.bind(this); getPageUrl = (pageNum: number, chapterId: number = this.chapterId) => { @@ -301,41 +337,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return Math.max(Math.min(this.pageNum, this.maxPages - 1), 0); } - /** - * Determines if we should render a double page. - * The general gist is if we are on double layout mode, the current page (first page) is not a cover image or a wide image - * and the next page is not a wide image (as only non-wides should be shown next to each other). - * @remarks This will always fail if the window's width is greater than the height - */ - get ShouldRenderDoublePage() { - if (this.layoutMode !== LayoutMode.Double) return false; - - return !( - this.isCoverImage() - || this.isWideImage(this.canvasImage) - || this.isWideImage(this.canvasImageNext) - ); - } - - /** - * We should Render 2 pages if: - * 1. We are not currently the first image (cover image) - * 2. The previous page is not a cover image - * 3. The current page is not a wide image - * 4. The next page is not a wide image - */ - get ShouldRenderReverseDouble() { - if (this.layoutMode !== LayoutMode.DoubleReversed) return false; - - const result = !( - this.isCoverImage() - || this.isCoverImage(this.pageNum - 1) // This is because we use prev page and hence the cover will re-show - || this.isWideImage(this.canvasImage) - || this.isWideImage(this.canvasImageNext) - ); - - return result; - } get CurrentPageBookmarked() { return this.bookmarks.hasOwnProperty(this.pageNum); @@ -345,20 +346,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return this.readingArea?.nativeElement.scrollWidth + 'px'; } - get WindowHeight() { - return this.readingArea?.nativeElement.scrollHeight + 'px'; - } - - get ImageWidth() { - return this.image?.nativeElement.width + 'px'; - } - get ImageHeight() { - // If we are a wide image and implied fit to screen, then we need to take screen height rather than image height - if (this.isWideImage() || this.FittingOption === FITTING_OPTION.WIDTH) { - return this.WindowHeight; - } - return Math.max(this.readingArea?.nativeElement?.clientHeight, this.image?.nativeElement.height) + 'px'; + // ?! This doesn't work reliably + //console.log('Reading Area Height: ', this.readingArea?.nativeElement?.clientHeight) + //console.log('Image 1 Height: ', this.document.querySelector('#image-1')?.clientHeight || 0) + //return 'calc(100*var(--vh))'; + return Math.max(this.readingArea?.nativeElement?.clientHeight, this.document.querySelector('#image-1')?.clientHeight || 0) + 'px'; } get RightPaginationOffset() { @@ -369,9 +362,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } get SplitIconClass() { - if (this.isSplitLeftToRight()) { + // NOTE: This could be rewritten to valueChanges.pipe(map()) and | async in the UI instead of the getter + if (this.mangaReaderService.isSplitLeftToRight(this.pageSplitOption)) { return 'left-side'; - } else if (this.isNoSplit()) { + } else if (this.mangaReaderService.isNoSplit(this.pageSplitOption)) { return 'none'; } return 'right-side'; @@ -410,7 +404,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { private toastr: ToastrService, private memberService: MemberService, public utilityService: UtilityService, private renderer: Renderer2, @Inject(DOCUMENT) private document: Document, private modalService: NgbModal, - private readonly cdRef: ChangeDetectorRef, private readonly ngZone: NgZone) { + private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService) { this.navService.hideNavBar(); this.navService.hideSideNav(); this.cdRef.markForCheck(); @@ -425,6 +419,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } + this.getPageFn = this.getPage.bind(this); + this.libraryId = parseInt(libraryId, 10); this.seriesId = parseInt(seriesId, 10); this.chapterId = parseInt(chapterId, 10); @@ -456,15 +452,44 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { 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, - darkness: 100 + this.generalSettingsForm = this.formBuilder.nonNullable.group({ + autoCloseMenu: new FormControl(this.autoCloseMenu), + pageSplitOption: new FormControl(this.pageSplitOption), + fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)), + layoutMode: new FormControl(this.layoutMode), + darkness: new FormControl(100) }); + this.readerModeSubject.next(this.readerMode); + this.pagingDirectionSubject.next(this.pagingDirection); + + + // We need a mergeMap when page changes + this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe( + takeUntil(this.onDestroy), + map(_ => this.createReaderSettingsUpdate()) + ); + this.updateForm(); + + this.pagingDirection$.pipe( + distinctUntilChanged(), + tap(dir => { + this.pagingDirection = dir; + this.cdRef.markForCheck(); + }), + takeUntil(this.onDestroy) + ).subscribe(() => {}); + + this.readerMode$.pipe( + distinctUntilChanged(), + tap(mode => { + this.readerMode = mode; + this.cdRef.markForCheck(); + }), + takeUntil(this.onDestroy) + ).subscribe(() => {}); + this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { @@ -477,7 +502,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit); this.generalSettingsForm.get('pageSplitOption')?.disable(); - this.generalSettingsForm.get('fittingOption')?.setValue(this.translateScalingOption(ScalingOption.FitToHeight)); + this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight)); this.generalSettingsForm.get('fittingOption')?.disable(); } this.cdRef.markForCheck(); @@ -490,9 +515,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => { this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; - const needsSplitting = this.isWideImage(); + this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10); + + const needsSplitting = this.mangaReaderService.isWideImage(this.canvasImage); // If we need to split on a menu change, then we need to re-render. if (needsSplitting) { + // If we need to re-render, to ensure things layout properly, let's update paging direction & reset render + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); + this.canvasRenderer.reset(); this.loadPage(); } }); @@ -524,11 +554,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (event.detail > 1) return; this.toggleMenu(); }); - - if (this.canvas) { - this.ctx = this.canvas.nativeElement.getContext('2d', { alpha: false }); - this.canvasImage.onload = () => this.renderPage(); - } } ngOnDestroy() { @@ -603,14 +628,26 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + createReaderSettingsUpdate() { + return { + pageSplit: parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10), + fitting: this.mangaReaderService.translateScalingOption(this.scalingOption), + layoutMode: this.layoutMode, + darkness: 100, + pagingDirection: this.pagingDirection, + readerMode: this.readerMode + }; + } + /** * Gets a page from cache else gets a brand new Image * @param pageNum Page Number to load * @param forceNew Forces to fetch a new image - * @param chapterId ChapterId to fetch page from. Defaults to current chapterId + * @param chapterId ChapterId to fetch page from. Defaults to current chapterId. Does not search against cached images with chapterId * @returns */ getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) { + // ?! This doesn't compare with chapterId, only for fetching let img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum); if (!img || forceNew) { img = new Image(); @@ -635,7 +672,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return true; } + // This is menu code clickOverlayClass(side: 'right' | 'left') { + // TODO: This needs to be validated with subject if (!this.showClickOverlay) { return ''; } @@ -653,12 +692,17 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.prevChapterDisabled = false; this.nextChapterPrefetched = false; this.pageNum = 0; - this.pagingDirection = PAGING_DIRECTION.FORWARD; + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); this.inSetup = true; this.canvasImage.src = ''; this.canvasImage2.src = ''; this.cdRef.markForCheck(); + this.cachedImages = []; + for (let i = 0; i < PREFETCH_PAGES; i++) { + this.cachedImages.push(new Image()); + } + if (this.goToPageEvent) { // There was a bug where goToPage was emitting old values into infinite scroller between chapter loads. We explicity clear it out between loads // and we use a BehaviourSubject to ensure only latest value is sent @@ -680,7 +724,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.inSetup = false; this.cdRef.markForCheck(); - this.cachedImages = []; for (let i = 0; i < PREFETCH_PAGES; i++) { this.cachedImages.push(new Image()) } @@ -714,9 +757,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setPageNum(page); this.goToPageEvent = new BehaviorSubject(this.pageNum); - - - // Due to change detection rules in Angular, we need to re-create the options object to apply the change const newOptions: Options = Object.assign({}, this.pageOptions); newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything. @@ -729,7 +769,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.inSetup = false; - // From bookmarks, create map of pages to make lookup time O(1) this.bookmarks = {}; results.bookmarks.forEach(bookmark => { @@ -754,15 +793,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); } else { // Fetch the last page of prev chapter - this.getPage(1000000, this.nextChapterId); + this.getPage(1000000, this.prevChapterId); } }); - this.cachedImages = []; - for (let i = 0; i < PREFETCH_PAGES; i++) { - this.cachedImages.push(new Image()); - } - this.render(); }, () => { @@ -785,68 +819,48 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - translateScalingOption(option: ScalingOption) { - switch (option) { - case (ScalingOption.Automatic): - { - const windowWidth = window.innerWidth - || document.documentElement.clientWidth - || document.body.clientWidth; - const windowHeight = window.innerHeight - || document.documentElement.clientHeight - || document.body.clientHeight; - - const ratio = windowWidth / windowHeight; - if (windowHeight > windowWidth) { - return FITTING_OPTION.WIDTH; - } - - if (windowWidth >= windowHeight || ratio > 1.0) { - return FITTING_OPTION.HEIGHT; - } - return FITTING_OPTION.WIDTH; - } - case (ScalingOption.FitToHeight): - return FITTING_OPTION.HEIGHT; - case (ScalingOption.FitToWidth): - return FITTING_OPTION.WIDTH; - default: - return FITTING_OPTION.ORIGINAL; - } - } - getFittingOptionClass() { const formControl = this.generalSettingsForm.get('fittingOption'); let val = FITTING_OPTION.HEIGHT; if (formControl === undefined) { - val = FITTING_OPTION.HEIGHT; + val = FITTING_OPTION.HEIGHT; } - val = formControl?.value; + val = formControl?.value; + if ( - this.isWideImage() && + this.mangaReaderService.isWideImage(this.canvasImage) && this.layoutMode === LayoutMode.Single && val !== FITTING_OPTION.WIDTH && - this.shouldRenderAsFitSplit() + this.mangaReaderService.shouldRenderAsFitSplit(this.generalSettingsForm.get('pageSplitOption')?.value) ) { // Rewriting to fit to width for this cover image + this.imageFitClass.next(FITTING_OPTION.WIDTH); + this.imageFit.next(FITTING_OPTION.WIDTH); return FITTING_OPTION.WIDTH; } - if (this.isWideImage() && this.layoutMode !== LayoutMode.Single) { + // TODO: Move this to double renderer + if (this.mangaReaderService.isWideImage(this.canvasImage) && this.layoutMode !== LayoutMode.Single) { + this.imageFitClass.next(val + ' wide double'); return val + ' wide double'; } - if (this.isCoverImage() && this.layoutMode !== LayoutMode.Single) { + // TODO: Move this to double renderer + if (this.mangaReaderService.isCoverImage(this.pageNum) && this.layoutMode !== LayoutMode.Single) { + this.imageFitClass.next(val + ' cover double'); return val + ' cover double'; } + this.imageFitClass.next(val); + this.imageFit.next(val); return val; } + getFittingIcon() { const value = this.getFit(); - + // TODO: This can be a pipe switch(value) { case FITTING_OPTION.HEIGHT: return 'fa-arrows-alt-v'; @@ -858,6 +872,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } getFit() { + // TODO: getFit can be refactored with typed form controls so we don't need this + // can't this also just be this.generalSettingsForm.get('fittingOption')?.value || FITTING_OPTION.HEIGHT let value = FITTING_OPTION.HEIGHT; const formControl = this.generalSettingsForm.get('fittingOption'); if (formControl !== undefined) { @@ -910,57 +926,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - isSplitLeftToRight() { - return parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) === PageSplitOption.SplitLeftToRight; - } - - /** - * - * @returns If the current model reflects no split of fit split - * @remarks Fit to Screen falls under no split - */ - isNoSplit() { - const splitValue = parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10); - return splitValue === PageSplitOption.NoSplit || splitValue === PageSplitOption.FitSplit; - } - - updateSplitPage() { - const needsSplitting = this.isWideImage(); - if (!needsSplitting || this.isNoSplit()) { - this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT; - return; - } - - if (this.pagingDirection === PAGING_DIRECTION.FORWARD) { - switch (this.currentImageSplitPart) { - case SPLIT_PAGE_PART.NO_SPLIT: - this.currentImageSplitPart = this.isSplitLeftToRight() ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.RIGHT_PART; - break; - case SPLIT_PAGE_PART.LEFT_PART: - const r2lSplittingPart = (needsSplitting ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.NO_SPLIT); - this.currentImageSplitPart = this.isSplitLeftToRight() ? SPLIT_PAGE_PART.RIGHT_PART : r2lSplittingPart; - break; - case SPLIT_PAGE_PART.RIGHT_PART: - const l2rSplittingPart = (needsSplitting ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.NO_SPLIT); - this.currentImageSplitPart = this.isSplitLeftToRight() ? l2rSplittingPart : SPLIT_PAGE_PART.LEFT_PART; - break; - } - } else if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { - switch (this.currentImageSplitPart) { - case SPLIT_PAGE_PART.NO_SPLIT: - this.currentImageSplitPart = this.isSplitLeftToRight() ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.LEFT_PART; - break; - case SPLIT_PAGE_PART.LEFT_PART: - const l2rSplittingPart = (needsSplitting ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.NO_SPLIT); - this.currentImageSplitPart = this.isSplitLeftToRight() ? l2rSplittingPart : SPLIT_PAGE_PART.RIGHT_PART; - break; - case SPLIT_PAGE_PART.RIGHT_PART: - this.currentImageSplitPart = this.isSplitLeftToRight() ? SPLIT_PAGE_PART.LEFT_PART : (needsSplitting ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.NO_SPLIT); - break; - } - } - } - onSwipeEvent(event: any) { console.log('Swipe event occured: ', event); } @@ -987,50 +952,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.preventDefault(); } - let pageAmount = 1; + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); - // If we are on the cover image, always do 1 page - - if (!this.isCoverImage()) { - if (this.layoutMode === LayoutMode.Double) { - pageAmount = ( - !this.isCoverImage() && - !this.isWideImage() && - !this.isWideImage(this.canvasImageNext) && - !this.isSecondLastImage() && - !this.isLastImage() - ? 2 : 1); - } else if (this.layoutMode === LayoutMode.DoubleReversed) { - // Move forward by 1 pages if: - // 1. The next page is a wide image - // 2. The next page + 1 is a wide image (why do we care at this point?) - // 3. We are on the second to last page - // 4. We are on the last page - pageAmount = !( - this.isWideImage(this.canvasImageNext) - || this.isWideImage(this.canvasImageAheadBy2) // Remember we are doing this logic before we've hit the next page, so we need this - || this.isSecondLastImage() - || this.isLastImage() - ) ? 2 : 1; - } - } - - - const notInSplit = this.currentImageSplitPart !== (this.isSplitLeftToRight() ? SPLIT_PAGE_PART.LEFT_PART : SPLIT_PAGE_PART.RIGHT_PART); - if ((this.pageNum + pageAmount >= this.maxPages && notInSplit) || this.isLoading) { - - if (this.isLoading) { return; } + const pageAmount = Math.max(this.canvasRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), this.singleRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), + this.doubleRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), + this.doubleReverseRenderer.getPageAmount(PAGING_DIRECTION.FORWARD)); + const notInSplit = this.canvasRenderer.shouldMovePrev(); // TODO: Make this generic like above, but by default only canvasRenderer will have logic + //console.log('Next Page, in split: ', !notInSplit, ' page amt: ', pageAmount, ' page: ', this.canvasImage.src); + if ((this.pageNum + pageAmount >= this.maxPages && notInSplit)) { // Move to next volume/chapter automatically this.loadNextChapter(); return; } - this.pagingDirection = PAGING_DIRECTION.FORWARD; - if (this.isNoSplit() || notInSplit) { - this.setPageNum(this.pageNum + pageAmount); - } - + this.setPageNum(this.pageNum + pageAmount); this.loadPage(); } @@ -1039,37 +975,24 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } + this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS); - const notInSplit = this.currentImageSplitPart !== (this.isSplitLeftToRight() ? SPLIT_PAGE_PART.RIGHT_PART : SPLIT_PAGE_PART.LEFT_PART); - let pageAmount = 1; - if (this.layoutMode === LayoutMode.Double) { - pageAmount = !( - this.isCoverImage() - || this.isWideImage(this.canvasImagePrev) - ) ? 2 : 1; - } else if (this.layoutMode === LayoutMode.DoubleReversed) { - pageAmount = !( - this.isCoverImage() - || this.isCoverImage(this.pageNum - 1) - || this.isWideImage(this.canvasImage) // JOE: At this point, these aren't yet set to the new values - || this.isWideImage(this.canvasImagePrev) // This should be Prev, if prev image (original: canvasImageNext) - ) ? 2 : 1; - } + const pageAmount = Math.max(this.canvasRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS), + this.singleRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS), + this.doubleRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS), + this.doubleReverseRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS)); - if ((this.pageNum - 1 < 0 && notInSplit) || this.isLoading) { - if (this.isLoading) { return; } + const notInSplit = this.canvasRenderer.shouldMovePrev(); + //console.log('Prev Page, not in split: ', notInSplit, ' page amt: ', pageAmount); + if ((this.pageNum - 1 < 0 && notInSplit)) { // Move to next volume/chapter automatically this.loadPrevChapter(); return; } - - this.pagingDirection = PAGING_DIRECTION.BACKWARDS; - if (this.isNoSplit() || notInSplit) { - this.setPageNum(this.pageNum - pageAmount); - } - + + this.setPageNum(this.pageNum - pageAmount); this.loadPage(); } @@ -1077,10 +1000,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Sets canvasImage's src to current page, but first attempts to use a pre-fetched image */ setCanvasImage() { - this.canvasImage = this.getPage(this.pageNum); - this.canvasImage.onload = () => { - this.renderPage(); - }; + if (this.cachedImages === undefined) return; + this.canvasImage = this.getPage(this.pageNum, this.chapterId, this.layoutMode !== LayoutMode.Single); + this.canvasImage.addEventListener('load', () => { + this.currentImage.next(this.canvasImage); + //this.renderPage(); // This can execute before cachedImages are ready + }, false); this.cdRef.markForCheck(); } @@ -1152,72 +1077,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - /** - * There are some hard limits on the size of canvas' that we must cap at. https://github.com/jhildenbiddle/canvas-size#test-results - * For Safari, it's 16,777,216, so we cap at 4096x4096 when this happens. The drawImage in render will perform bi-cubic scaling for us. - * @returns If we should continue to the render loop - */ - setCanvasSize() { - if (this.ctx && this.canvas) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const isSafari = [ - 'iPad Simulator', - 'iPhone Simulator', - 'iPod Simulator', - 'iPad', - 'iPhone', - 'iPod' - ].includes(navigator.platform) - // iPad on iOS 13 detection - || (navigator.userAgent.includes("Mac") && "ontouchend" in document); - const canvasLimit = isSafari ? 16_777_216 : 124_992_400; - const needsScaling = this.canvasImage.width * this.canvasImage.height > canvasLimit; - if (needsScaling) { - this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384; - this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384; - } else { - this.canvas.nativeElement.width = this.canvasImage.width; - this.canvas.nativeElement.height = this.canvasImage.height; - } - this.cdRef.markForCheck(); - } - } + renderPage() { - const needsSplitting = this.isWideImage(); - - if (!this.ctx || !this.canvas || this.isNoSplit() || !needsSplitting) { - this.renderWithCanvas = false; - if (this.getFit() !== FITTING_OPTION.HEIGHT) { - this.readingArea.nativeElement.scroll(0,0); - } - this.isLoading = false; - this.cdRef.markForCheck(); - return; - } + console.log('[Manga Reader] renderPage()'); - this.renderWithCanvas = true; - this.canvasImage.onload = null; + const page = [this.canvasImage]; + this.canvasRenderer.renderPage(page); + this.singleRenderer.renderPage(page); + this.doubleRenderer.renderPage(page); + this.doubleReverseRenderer.renderPage(page); - this.setCanvasSize(); - this.updateSplitPage(); - - if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) { - this.canvas.nativeElement.width = this.canvasImage.width / 2; - this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, 0, 0, this.canvasImage.width, this.canvasImage.height); - this.cdRef.markForCheck(); - } else if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.RIGHT_PART) { - this.canvas.nativeElement.width = this.canvasImage.width / 2; - this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height); - this.cdRef.markForCheck(); - } - - // Reset scroll on non HEIGHT Fits if (this.getFit() !== FITTING_OPTION.HEIGHT) { - this.readingArea.nativeElement.scroll(0,0); + this.readingArea.nativeElement.scroll(0,0); } - this.isLoading = false; + this.cdRef.markForCheck(); } @@ -1229,7 +1103,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { || document.documentElement.clientHeight || document.body.clientHeight; - const needsSplitting = this.isWideImage(); + const needsSplitting = this.mangaReaderService.isWideImage(this.canvasImage); let newScale = this.FittingOption; const widthRatio = windowWidth / (this.canvasImage.width / (needsSplitting ? 2 : 1)); const heightRatio = windowHeight / (this.canvasImage.height); @@ -1245,51 +1119,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.generalSettingsForm.get('fittingOption')?.setValue(newScale, {emitEvent: false}); } - /** - * If pagenumber is 0 aka first page, which on double page rendering should always render as a single. - * - * @param pageNumber Defaults to current page number - * @returns - */ - isCoverImage(pageNumber = this.pageNum) { - return pageNumber === 0; - } - - /** - * If the image's width is greater than it's height - * @param elem Optional Image - */ - isWideImage(elem?: HTMLImageElement) { - if (elem) { - elem.onload = () => { - return elem.width > elem.height; - } - if (elem.src === '') return false; - } - const element = elem || this.canvasImage; - return element.width > element.height; - } - - /** - * If the current page is second to last image - */ - isSecondLastImage() { - return this.maxPages - 1 - this.pageNum === 1; - } - - /** - * If the current image is last image - */ - isLastImage() { - return this.maxPages - 1 === this.pageNum; - } - - shouldRenderAsFitSplit() { - // Some pages aren't cover images but might need fit split renderings - if (parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false; - return true; - } - /** * Maintains an array of images (that are requested from backend) around the user's current page. This allows for quick loading (seemless to user) * and also maintains page info (wide image, etc) due to onload event. @@ -1306,17 +1135,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.readerService.imageUrlToPageNum(this.cachedImages[index].src) !== numOffset) { this.cachedImages[index] = new Image(); this.cachedImages[index].src = this.getPageUrl(numOffset); - this.cachedImages[index].onload = () => { - //console.log('Page ', numOffset, ' loaded'); - //this.cdRef.markForCheck(); - }; } } const pages = this.cachedImages.map(img => this.readerService.imageUrlToPageNum(img.src)); const pagesBefore = pages.filter(p => p >= 0 && p < this.pageNum).length; const pagesAfter = pages.filter(p => p >= 0 && p > this.pageNum).length; - console.log('Buffer Health: Before: ', pagesBefore, ' After: ', pagesAfter); + //console.log('Buffer Health: Before: ', pagesBefore, ' After: ', pagesAfter); console.log(this.pageNum, ' Prefetched pages: ', pages.map(p => { if (this.pageNum === p) return '[' + p + ']'; return '' + p @@ -1324,66 +1149,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } + /** + * This is responsible for setting up the image variables. This will be moved out to different renderers + */ loadPage() { if (this.readerMode === ReaderMode.Webtoon) return; this.isLoading = true; - this.canvasImage2.src = ''; - this.canvasImageAheadBy2.src = ''; - - this.setCanvasImage(); - - - // ?! This logic is hella complex and confusing to read - // ?! We need to refactor into separate methods and keep it clean - // ?! In addition, we shouldn't update canvasImage outside of this code - - if (this.layoutMode !== LayoutMode.Single) { - - this.canvasImageNext = new Image(); - - - // If prev page was a spread, then we don't do + 1 - console.log('Current canvas image page: ', this.readerService.imageUrlToPageNum(this.canvasImage.src)); - console.log('Prev canvas image page: ', this.readerService.imageUrlToPageNum(this.canvasImage2.src)); - // if (this.isWideImage(this.canvasImage2)) { - // this.canvasImagePrev = this.getPage(this.pageNum); // this.getPageUrl(this.pageNum); - // console.log('Setting Prev to ', this.pageNum); - // } else { - // this.canvasImagePrev = this.getPage(this.pageNum - 1); //this.getPageUrl(this.pageNum - 1); - // console.log('Setting Prev to ', this.pageNum - 1); - // } - - // TODO: Validate this statement: This needs to be capped at maxPages !this.isLastImage() - this.canvasImageNext = this.getPage(this.pageNum + 1); - console.log('Setting Next to ', this.pageNum + 1); - - this.canvasImagePrev = this.getPage(this.pageNum - 1); - console.log('Setting Prev to ', this.pageNum - 1); - - if (this.pageNum + 2 < this.maxPages - 1) { - this.canvasImageAheadBy2 = this.getPage(this.pageNum + 2); - } - if (this.pageNum - 2 >= 0) { - this.canvasImageBehindBy2 = this.getPage(this.pageNum - 2 || 0); - } - - if (this.ShouldRenderDoublePage || this.ShouldRenderReverseDouble) { - console.log('Rendering Double Page'); - if (this.layoutMode === LayoutMode.Double) { - this.canvasImage2 = this.canvasImageNext; - } else { - this.canvasImage2 = this.canvasImagePrev; - } - } - } - - this.cdRef.markForCheck(); + this.renderPage(); - this.prefetch(); + this.isLoading = false; this.cdRef.markForCheck(); + + this.prefetch(); } setReadingDirection() { @@ -1395,8 +1175,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.menuOpen && this.user.preferences.showScreenHints) { this.showClickOverlay = true; + this.showClickOverlaySubject.next(true); setTimeout(() => { this.showClickOverlay = false; + this.showClickOverlaySubject.next(false); }, CLICK_OVERLAY_TIMEOUT); } } @@ -1414,9 +1196,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { const page = context.value; if (page > this.pageNum) { - this.pagingDirection = PAGING_DIRECTION.FORWARD; + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); } else { - this.pagingDirection = PAGING_DIRECTION.BACKWARDS; + this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS); } this.setPageNum(page); @@ -1427,6 +1209,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { setPageNum(pageNum: number) { this.pageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0); + this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages}); this.cdRef.markForCheck(); if (this.pageNum >= this.maxPages - 10) { @@ -1471,9 +1254,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (page > this.pageNum) { - this.pagingDirection = PAGING_DIRECTION.FORWARD; + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); } else { - this.pagingDirection = PAGING_DIRECTION.BACKWARDS; + this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS); } this.setPageNum(page); @@ -1481,12 +1264,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.render(); } + // This is menu only code promptForPage() { const goToPageNum = window.prompt('There are ' + this.maxPages + ' pages. What page would you like to go to?', ''); if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; } return goToPageNum; } + // This is menu only code toggleFullscreen() { this.isFullscreen = this.readerService.checkFullscreenMode(); if (this.isFullscreen) { @@ -1504,24 +1289,25 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - + // This is menu only code toggleReaderMode() { switch(this.readerMode) { case ReaderMode.LeftRight: - this.readerMode = ReaderMode.UpDown; - this.pagingDirection = PAGING_DIRECTION.FORWARD; + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); + this.readerModeSubject.next(ReaderMode.UpDown); break; case ReaderMode.UpDown: - this.readerMode = ReaderMode.Webtoon; + this.readerModeSubject.next(ReaderMode.Webtoon); break; case ReaderMode.Webtoon: - this.readerMode = ReaderMode.LeftRight; + this.readerModeSubject.next(ReaderMode.LeftRight); break; } // We must set this here because loadPage from render doesn't call if we aren't page splitting if (this.readerMode !== ReaderMode.Webtoon) { this.canvasImage = this.cachedImages[this.pageNum & this.cachedImages.length]; + this.currentImage.next(this.canvasImage); this.isLoading = true; } @@ -1530,6 +1316,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.render(); } + // This is menu only code updateForm() { if ( this.readerMode === ReaderMode.Webtoon) { this.generalSettingsForm.get('pageSplitOption')?.disable() @@ -1583,31 +1370,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Show an effect on the image to show that it was bookmarked this.showBookmarkEffectEvent.next(pageNum); - if (this.readerMode === ReaderMode.Webtoon) return; - - let elements:Array = []; - if (this.renderWithCanvas && this.canvas) { - elements.push(this.canvas?.nativeElement); - } else { - const image1 = this.document.querySelector('#image-1'); - if (image1 != null) elements.push(image1); - - if (this.layoutMode !== LayoutMode.Single) { - const image2 = this.document.querySelector('#image-2'); - if (image2 != null) elements.push(image2); - } - } - - - if (elements.length > 0) { - elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect')); - setTimeout(() => { - elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect')); - }, 1000); - } - } + // This is menu only code /** * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state */ @@ -1621,6 +1386,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + // This is menu only code openShortcutModal() { let ref = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' }); ref.componentInstance.shortcuts = [ @@ -1630,6 +1396,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { {key: '↓', description: 'Move to previous page'}, {key: 'G', description: 'Open Go to Page dialog'}, {key: 'B', description: 'Bookmark current page'}, + {key: 'double click', description: 'Bookmark current page'}, {key: 'ESC', description: 'Close reader'}, {key: 'SPACE', description: 'Toggle Menu'}, ]; diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html new file mode 100644 index 000000000..e92dac737 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html @@ -0,0 +1,10 @@ +
+ +  + +
\ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.scss b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.scss new file mode 100644 index 000000000..daeafd50b --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.scss @@ -0,0 +1 @@ +@use '../../../../manga-reader-common'; \ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts new file mode 100644 index 000000000..cd25e34cb --- /dev/null +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts @@ -0,0 +1,141 @@ +import { DOCUMENT } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { filter, map, Observable, of, Subject, takeUntil, tap, zip } from 'rxjs'; +import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; +import { ReaderMode } from 'src/app/_models/preferences/reader-mode'; +import { LayoutMode } from '../../_models/layout-mode'; +import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; +import { ReaderSetting } from '../../_models/reader-setting'; +import { ImageRenderer } from '../../_models/renderer'; +import { ManagaReaderService } from '../../_series/managa-reader.service'; + +@Component({ + selector: 'app-single-renderer', + templateUrl: './single-renderer.component.html', + styleUrls: ['./single-renderer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SingleRendererComponent implements OnInit, OnDestroy, ImageRenderer { + + @Input() readerSettings$!: Observable; + @Input() image$!: Observable; + /** + * The image fit class + */ + @Input() imageFit$!: Observable; + @Input() bookmark$!: Observable; + @Input() showClickOverlay$!: Observable; + + @Output() imageHeight: EventEmitter = new EventEmitter(); + + imageFitClass$!: Observable; + showClickOverlayClass$!: Observable; + readerModeClass$!: Observable; + darkenss$: Observable = of('brightness(100%)'); + currentImage!: HTMLImageElement; + layoutMode: LayoutMode = LayoutMode.Single; + pageSplit: PageSplitOption = PageSplitOption.FitSplit; + + private readonly onDestroy = new Subject(); + + get ReaderMode() {return ReaderMode;} + get LayoutMode() {return LayoutMode;} + + constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: ManagaReaderService, + @Inject(DOCUMENT) private document: Document) { } + + ngOnInit(): void { + this.readerModeClass$ = this.readerSettings$.pipe( + filter(_ => this.isValid()), + map(values => values.readerMode), + map(mode => mode === ReaderMode.LeftRight || mode === ReaderMode.UpDown ? '' : 'd-none'), + takeUntil(this.onDestroy) + ); + + this.darkenss$ = this.readerSettings$.pipe( + filter(_ => this.isValid()), + map(values => 'brightness(' + values.darkness + '%)'), + takeUntil(this.onDestroy) + ); + + this.showClickOverlayClass$ = this.showClickOverlay$.pipe( + filter(_ => this.isValid()), + map(showOverlay => showOverlay ? 'blur' : ''), + takeUntil(this.onDestroy) + ); + + this.readerSettings$.pipe( + takeUntil(this.onDestroy), + tap(values => { + this.layoutMode = values.layoutMode; + this.pageSplit = values.pageSplit; + this.cdRef.markForCheck(); + }) + ).subscribe(() => {}); + + this.bookmark$.pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + tap(_ => { + const elements = []; + const image1 = this.document.querySelector('#image-1'); + if (image1 != null) elements.push(image1); + this.mangaReaderService.applyBookmarkEffect(elements); + }) + ).subscribe(() => {}); + + this.imageFitClass$ = zip(this.readerSettings$, this.image$).pipe( + takeUntil(this.onDestroy), + filter(_ => this.isValid()), + map(values => values[0].fitting), + map(fit => { + if ( + this.mangaReaderService.isWideImage(this.currentImage) && + this.layoutMode === LayoutMode.Single && + fit !== FITTING_OPTION.WIDTH && + this.mangaReaderService.shouldRenderAsFitSplit(this.pageSplit) + ) { + // Rewriting to fit to width for this cover image + console.log('overridding for fit to screen'); + return FITTING_OPTION.WIDTH; + } + return fit; + }) + ); + } + + isValid() { + return this.layoutMode === LayoutMode.Single; + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + renderPage(img: Array): void { + if (img === null || img.length === 0 || img[0] === null) return; + if (!this.isValid()) return; + + // This seems to cause a problem after rendering a split + //if (this.mangaReaderService.shouldSplit(this.currentImage, this.pageSplit)) return; + + + + this.currentImage = img[0]; + this.cdRef.markForCheck(); + this.imageHeight.emit(this.currentImage.height); + } + + shouldMovePrev(): boolean { + return true; + } + shouldMoveNext(): boolean { + return true; + } + getPageAmount(direction: PAGING_DIRECTION): number { + if (!this.isValid() || this.mangaReaderService.shouldSplit(this.currentImage, this.pageSplit)) return 0; + return 1; + } + reset(): void {} +} diff --git a/UI/Web/src/app/manga-reader/_models/reader-setting.ts b/UI/Web/src/app/manga-reader/_models/reader-setting.ts new file mode 100644 index 000000000..aae62ed7c --- /dev/null +++ b/UI/Web/src/app/manga-reader/_models/reader-setting.ts @@ -0,0 +1,13 @@ +import { PageSplitOption } from "src/app/_models/preferences/page-split-option"; +import { ReaderMode } from "src/app/_models/preferences/reader-mode"; +import { LayoutMode } from "./layout-mode"; +import { FITTING_OPTION, PAGING_DIRECTION } from "./reader-enums"; + +export interface ReaderSetting { + pageSplit: PageSplitOption; + fitting: FITTING_OPTION; + layoutMode: LayoutMode; + darkness: number; + pagingDirection: PAGING_DIRECTION; + readerMode: ReaderMode; +} \ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/_models/renderer.ts b/UI/Web/src/app/manga-reader/_models/renderer.ts new file mode 100644 index 000000000..530a5c128 --- /dev/null +++ b/UI/Web/src/app/manga-reader/_models/renderer.ts @@ -0,0 +1,45 @@ +import { Observable } from "rxjs"; +import { PAGING_DIRECTION } from "./reader-enums"; +import { ReaderSetting } from "./reader-setting"; + +/** + * A generic interface for an image renderer + */ +export interface ImageRenderer { + + /** + * Updates with menu items that may affect renderer. This keeps reader and menu/parent in sync. + */ + readerSettings$: Observable; + /** + * The current Image + */ + image$: Observable; + /** + * When a page is bookmarked or unbookmarked. Emits with page number. + */ + bookmark$: Observable; + /** + * Performs a rendering pass. This is passed one or more images to render from prefetcher + */ + renderPage(img: Array): void; + /** + * If a valid move next page should occur, this will return true. Otherwise, this will return false. + */ + shouldMovePrev(): boolean; + /** + * If a valid move prev page should occur, this will return true. Otherwise, this will return false. + */ + shouldMoveNext(): boolean; + /** + * Returns the number of pages that should occur based on page direction and internal state of the renderer. + */ + getPageAmount(direction: PAGING_DIRECTION): number; + /** + * When layout shifts occur, where a re-render might be needed but from menu option (like split option changed on a split image), this will be called. + * This should reset any needed state, but not unset the image. + */ + reset(): void; + + +} \ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts b/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts new file mode 100644 index 000000000..9ea45502a --- /dev/null +++ b/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts @@ -0,0 +1,138 @@ +import { DOCUMENT } from '@angular/common'; +import { ElementRef, Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; +import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; +import { ScalingOption } from 'src/app/_models/preferences/scaling-option'; +import { ReaderService } from 'src/app/_services/reader.service'; +import { FITTING_OPTION } from '../_models/reader-enums'; + +@Injectable({ + providedIn: 'root' +}) +export class ManagaReaderService { + + private renderer: Renderer2; + constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private readerService: ReaderService) { + this.renderer = rendererFactory.createRenderer(null, null); + } + + + + /** + * If the image's width is greater than it's height + * @param elem Image + */ + isWideImage(elem: HTMLImageElement) { + if (!elem) return false; + if (elem) { + elem.addEventListener('load', () => { + return elem.width > elem.height; + }, false); + if (elem.src === '') return false; + } + return elem.width > elem.height; + } + + /** + * If pagenumber is 0 aka first page, which on double page rendering should always render as a single. + * + * @param pageNumber current page number + * @returns + */ + isCoverImage(pageNumber: number) { + return pageNumber === 0; + } + + /** + * Does the image need + * @returns If the current model reflects no split of fit split + * @remarks Fit to Screen falls under no split + */ + isNoSplit(pageSplitOption: PageSplitOption) { + const splitValue = parseInt(pageSplitOption + '', 10); // Just in case it's a string from form + return splitValue === PageSplitOption.NoSplit || splitValue === PageSplitOption.FitSplit; + } + + /** + * If the split option is Left to Right. This means that the Left side of the image renders before the Right side. + * In other words, If you were to visualize the parts as pages, Left is Page 0, Right is Page 1 + */ + isSplitLeftToRight(pageSplitOption: PageSplitOption) { + return parseInt(pageSplitOption + '', 10) === PageSplitOption.SplitLeftToRight; + } + + /** + * If the current page is second to last image + */ + isSecondLastImage(pageNum: number, maxPages: number) { + return maxPages - 1 - pageNum === 2; + } + + /** + * If the current image is last image + */ + isLastImage(pageNum: number, maxPages: number) { + return maxPages - 1 === pageNum; + } + + /** + * Should Canvas Renderer be used + * @param img + * @param pageSplitOption + * @returns + */ + shouldSplit(img: HTMLImageElement, pageSplitOption: PageSplitOption) { + const needsSplitting = this.isWideImage(img); + return !(this.isNoSplit(pageSplitOption) || !needsSplitting) + } + + shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) { + // Some pages aren't cover images but might need fit split renderings + if (parseInt(pageSplitOption + '', 10) !== PageSplitOption.FitSplit) return false; + return true; + } + + + translateScalingOption(option: ScalingOption) { + switch (option) { + case (ScalingOption.Automatic): + { + const windowWidth = window.innerWidth + || document.documentElement.clientWidth + || document.body.clientWidth; + const windowHeight = window.innerHeight + || document.documentElement.clientHeight + || document.body.clientHeight; + + const ratio = windowWidth / windowHeight; + if (windowHeight > windowWidth) { + return FITTING_OPTION.WIDTH; + } + + if (windowWidth >= windowHeight || ratio > 1.0) { + return FITTING_OPTION.HEIGHT; + } + return FITTING_OPTION.WIDTH; + } + case (ScalingOption.FitToHeight): + return FITTING_OPTION.HEIGHT; + case (ScalingOption.FitToWidth): + return FITTING_OPTION.WIDTH; + default: + return FITTING_OPTION.ORIGINAL; + } + } + + + applyBookmarkEffect(elements: Array) { + if (elements.length > 0) { + elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect')); + setTimeout(() => { + elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect')); + }, 1000); + } + } + + + + +} diff --git a/UI/Web/src/app/manga-reader/manga-reader.module.ts b/UI/Web/src/app/manga-reader/manga-reader.module.ts index 4712fb2ec..00ed28a14 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.module.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.module.ts @@ -1,17 +1,22 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { MangaReaderComponent } from './manga-reader.component'; import { ReactiveFormsModule } from '@angular/forms'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { MangaReaderRoutingModule } from './manga-reader.router.module'; import { SharedModule } from '../shared/shared.module'; import { NgxSliderModule } from '@angular-slider/ngx-slider'; -import { InfiniteScrollerComponent } from './infinite-scroller/infinite-scroller.component'; +import { InfiniteScrollerComponent } from './_components/infinite-scroller/infinite-scroller.component'; import { ReaderSharedModule } from '../reader-shared/reader-shared.module'; import { PipeModule } from '../pipe/pipe.module'; import { FullscreenIconPipe } from './_pipes/fullscreen-icon.pipe'; import { LayoutModeIconPipe } from './_pipes/layout-mode-icon.pipe'; import { ReaderModeIconPipe } from './_pipes/reader-mode-icon.pipe'; +import { SwipeDirective } from './swipe.directive'; +import { CanvasRendererComponent } from './_components/canvas-renderer/canvas-renderer.component'; +import { SingleRendererComponent } from './_components/single-renderer/single-renderer.component'; +import { DoubleRendererComponent } from './_components/double-renderer/double-renderer.component'; +import { DoubleReverseRendererComponent } from './_components/double-reverse-renderer/double-reverse-renderer.component'; +import { MangaReaderComponent } from './_components/manga-reader/manga-reader.component'; @NgModule({ declarations: [ @@ -20,6 +25,11 @@ import { ReaderModeIconPipe } from './_pipes/reader-mode-icon.pipe'; FullscreenIconPipe, ReaderModeIconPipe, LayoutModeIconPipe, + SwipeDirective, + CanvasRendererComponent, + SingleRendererComponent, + DoubleRendererComponent, + DoubleReverseRendererComponent, ], imports: [ CommonModule, diff --git a/UI/Web/src/app/manga-reader/manga-reader.router.module.ts b/UI/Web/src/app/manga-reader/manga-reader.router.module.ts index 680c7cde7..b97e57eb1 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.router.module.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.router.module.ts @@ -1,6 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { MangaReaderComponent } from './manga-reader.component'; +import { MangaReaderComponent } from './_components/manga-reader/manga-reader.component'; const routes: Routes = [ { diff --git a/UI/Web/src/app/manga-reader/swipe.directive.ts b/UI/Web/src/app/manga-reader/swipe.directive.ts new file mode 100644 index 000000000..b94533da0 --- /dev/null +++ b/UI/Web/src/app/manga-reader/swipe.directive.ts @@ -0,0 +1,39 @@ +import { Directive, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core'; +import { fromEvent, map, Observable } from 'rxjs'; + +/** + * Repsonsible for triggering a swipe event + */ +@Directive({ + selector: '[appSwipe]' +}) +export class SwipeDirective { + + @Input() threshold: number = 10; + @Output() swipeEvent: EventEmitter = new EventEmitter(); + + touchStarts$!: Observable; + touchMoves$!: Observable; + touchEnds$!: Observable; + touchCancels$!: Observable; + + @HostListener('touchstart') onTouchStart(event: TouchEvent) { + console.log('Touch Start: ', event); + } + + @HostListener('touchend') onTouchEnd(event: TouchEvent) { + console.log('Touch End: ', event); + } + + constructor(private el: ElementRef) { + this.touchStarts$ = fromEvent(el.nativeElement, 'touchstart').pipe(map(this.getTouchCoordinates)); + this.touchMoves$ = fromEvent(el.nativeElement, 'touchmove').pipe(map(this.getTouchCoordinates)); + this.touchEnds$ = fromEvent(el.nativeElement, 'touchend').pipe(map(this.getTouchCoordinates)); + this.touchCancels$ = fromEvent(el.nativeElement, 'touchcancel'); + } + + getTouchCoordinates(event: TouchEvent) { + + } + +} diff --git a/UI/Web/src/app/registration/_components/splash-container/splash-container.component.scss b/UI/Web/src/app/registration/_components/splash-container/splash-container.component.scss index e710a7c34..79c6ad7a7 100644 --- a/UI/Web/src/app/registration/_components/splash-container/splash-container.component.scss +++ b/UI/Web/src/app/registration/_components/splash-container/splash-container.component.scss @@ -11,7 +11,7 @@ &::before { content: ""; - background-image: url('../../../assets/images/login-bg.jpg'); + background-image: url('../../../../assets/images/login-bg.jpg'); background-size: cover; position: absolute; top: 0; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 593cfd3c7..4be6d367b 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -21,7 +21,6 @@ import { ScanSeriesEvent } from 'src/app/_models/events/scan-series-event'; import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'; import { LibraryType } from 'src/app/_models/library'; import { MangaFormat } from 'src/app/_models/manga-format'; -import { PageLayoutMode } from 'src/app/_models/readers/page-layout-mode'; import { ReadingList } from 'src/app/_models/reading-list'; import { Series } from 'src/app/_models/series'; import { RelatedSeries } from 'src/app/_models/series-detail/related-series'; @@ -42,6 +41,7 @@ import { ReadingListService } from 'src/app/_services/reading-list.service'; import { ScrollService } from 'src/app/_services/scroll.service'; import { SeriesService } from 'src/app/_services/series.service'; import { ReviewSeriesModalComponent } from '../../_modals/review-series-modal/review-series-modal.component'; +import { PageLayoutMode } from 'src/app/_models/page-layout-mode'; interface RelatedSeris { series: Series; diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 8273095f9..2c9984766 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -62,14 +62,12 @@ export class SelectionModel { * @returns boolean */ isSelected(data: T, compareFn?: SelectionCompareFn): boolean { - let dataItem: Array; - let lookupMethod = this.shallowEqual; if (compareFn != undefined || compareFn != null) { lookupMethod = compareFn; } - dataItem = this._data.filter(d => lookupMethod(d.value, data)); + const dataItem = this._data.filter(d => lookupMethod(d.value, data)); if (dataItem.length > 0) { return dataItem[0].selected; @@ -114,24 +112,18 @@ export class SelectionModel { return undefined; } - shallowEqual(object1: T, object2: T) { - if (object1 === undefined || object2 === undefined) return false; + shallowEqual(a: T, b: T) { - if (typeof(object1) === 'string' && typeof(object2) === 'string') return object1 === object2; - - const keys1 = Object.keys(object1); - const keys2 = Object.keys(object2); - - if (keys1.length !== keys2.length) { - return false; - } - - for (let key of keys1) { - if ((object1 as any)[key] !== (object2 as any)[key]) { + for (let key in a) { + if (!(key in b) || a[key] !== b[key]) { + return false; + } + } + for (let key in b) { + if (!(key in a)) { return false; } } - return true; } } diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index e250cfe44..027b97568 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -3,15 +3,15 @@ import { FormControl, FormGroup } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { take, takeUntil } from 'rxjs/operators'; import { Title } from '@angular/platform-browser'; -import { BookService } from 'src/app/book-reader/_services/book.service'; import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes, pageLayoutModes } from 'src/app/_models/preferences/preferences'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; import { ActivatedRoute, Router } from '@angular/router'; import { SettingsService } from 'src/app/admin/settings.service'; -import { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component'; import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode'; import { forkJoin, Subject } from 'rxjs'; +import { bookColorThemes } from 'src/app/book-reader/_components/reader-settings/reader-settings.component'; +import { BookService } from 'src/app/book-reader/_services/book.service'; enum AccordionPanelID { ImageReader = 'image-reader', diff --git a/UI/Web/src/theme/utilities/_animations.scss b/UI/Web/src/theme/utilities/_animations.scss index cd4041f84..158ddb36b 100644 --- a/UI/Web/src/theme/utilities/_animations.scss +++ b/UI/Web/src/theme/utilities/_animations.scss @@ -10,4 +10,13 @@ 50% { transform: translateY(-10px); } +} + +@keyframes bookmark { + 0%, 100% { + border: 0px; + } + 50% { + border: 5px solid var(--primary-color); + } } \ No newline at end of file