Manga Reader Refresh (#1137)

* Refactored manga reader to use a regular image element for all cases except for split page rendering

* Fixed a weird issue where ordering of routes broke redireciton in one case.

* Added comments to a lot of the enums and refactored READER_MODE to be ReaderMode and much more clearer on function.

* Added bookmark effect on image renderer

* Implemented keyboard shortcut modal

* Introduced the new layout mode into the manga reader, updated preferences, and updated bookmark to work for said functionality. Need to implement renderer now

* Hooked in ability to show double pages but all the css is broken. Committing for help from Robbie.

* Fixed an issue where Language tag in metadata edit wasn't being updated

* Fixed up some styling on mobile for edit series detail

* Some css fixes

* Hooked in ability to set background color on reader (not implemented in reader). Optimized some code in ArchiveService to avoid extra memory allocations.

* Hooked in background color, generated the migration

* Fixed a bug when paging to cover images, full height would be used instead of full-width for cover images

* New option in reader to show screen hints (on by default). You can disable in user preferences which will stop showing pagination overlay hints

* Lots of fixes for double rendering mode

* Bumped the amount of cached pages to 8

* Fixed an issue where dropdowns weren't being locked on form manipulation
This commit is contained in:
Joseph Milazzo 2022-03-07 11:35:27 -06:00 committed by GitHub
parent 3dedbb1465
commit 2a4d0d1cd1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 3607 additions and 226 deletions

View File

@ -310,7 +310,7 @@ namespace API.Tests.Services
[InlineData(new [] {"001.txt", "002.txt", "a.jpg"}, "Test.zip", "a.jpg")]
public void FindCoverImageFilename(string[] filenames, string archiveName, string expected)
{
Assert.Equal(expected, _archiveService.FindCoverImageFilename(archiveName, filenames));
Assert.Equal(expected, ArchiveService.FindCoverImageFilename(archiveName, filenames));
}

View File

@ -5,6 +5,7 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Extensions;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -14,10 +15,12 @@ namespace API.Controllers
public class UsersController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public UsersController(IUnitOfWork unitOfWork)
public UsersController(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
[Authorize(Policy = "RequireAdminRole")]
@ -71,7 +74,10 @@ namespace API.Controllers
existingPreferences.ScalingOption = preferencesDto.ScalingOption;
existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
existingPreferences.ReaderMode = preferencesDto.ReaderMode;
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor;
existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin;
existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing;
existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily;
@ -90,5 +96,13 @@ namespace API.Controllers
return BadRequest("There was an issue saving preferences.");
}
[HttpGet("get-preferences")]
public async Task<ActionResult<UserPreferencesDto>> GetPreferences()
{
return _mapper.Map<UserPreferencesDto>(
await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername()));
}
}
}

View File

@ -5,18 +5,74 @@ namespace API.DTOs
{
public class UserPreferencesDto
{
/// <summary>
/// Manga Reader Option: What direction should the next/prev page buttons go
/// </summary>
public ReadingDirection ReadingDirection { get; set; }
/// <summary>
/// Manga Reader Option: How should the image be scaled to screen
/// </summary>
public ScalingOption ScalingOption { get; set; }
/// <summary>
/// Manga Reader Option: Which side of a split image should we show first
/// </summary>
public PageSplitOption PageSplitOption { get; set; }
/// <summary>
/// Manga Reader Option: How the manga reader should perform paging or reading of the file
/// <example>
/// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging
/// by clicking top/bottom sides of reader.
/// </example>
/// </summary>
public ReaderMode ReaderMode { get; set; }
/// <summary>
/// Manga Reader Option: How many pages to display in the reader at once
/// </summary>
public LayoutMode LayoutMode { get; set; }
/// <summary>
/// Manga Reader Option: Background color of the reader
/// </summary>
public string BackgroundColor { get; set; } = "#000000";
/// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
/// </summary>
public bool AutoCloseMenu { get; set; }
/// <summary>
/// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
/// </summary>
public bool ShowScreenHints { get; set; } = true;
/// <summary>
/// Book Reader Option: Should the background color be dark
/// </summary>
public bool BookReaderDarkMode { get; set; } = false;
/// <summary>
/// Book Reader Option: Override extra Margin
/// </summary>
public int BookReaderMargin { get; set; }
/// <summary>
/// Book Reader Option: Override line-height
/// </summary>
public int BookReaderLineSpacing { get; set; }
/// <summary>
/// Book Reader Option: Override font size
/// </summary>
public int BookReaderFontSize { get; set; }
/// <summary>
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
/// </summary>
public string BookReaderFontFamily { get; set; }
/// <summary>
/// Book Reader Option: Allows tapping on side of screens to paginate
/// </summary>
public bool BookReaderTapToPaginate { get; set; }
/// <summary>
/// Book Reader Option: What direction should the next/prev page buttons go
/// </summary>
public ReadingDirection BookReaderReadingDirection { get; set; }
/// <summary>
/// UI Site Global Setting: The UI theme the user should use.
/// </summary>
/// <remarks>Should default to Dark</remarks>
public SiteTheme Theme { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
using API.Entities.Enums;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class MangaReaderBackgroundAndLayoutMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BackgroundColor",
table: "AppUserPreferences",
type: "TEXT",
defaultValue: "#000000",
nullable: false);
migrationBuilder.AddColumn<int>(
name: "LayoutMode",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: LayoutMode.Single);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BackgroundColor",
table: "AppUserPreferences");
migrationBuilder.DropColumn(
name: "LayoutMode",
table: "AppUserPreferences");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class ScreenHints : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ShowScreenHints",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowScreenHints",
table: "AppUserPreferences");
}
}
}

View File

@ -165,6 +165,9 @@ namespace API.Data.Migrations
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<string>("BackgroundColor")
.HasColumnType("TEXT");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
@ -186,6 +189,9 @@ namespace API.Data.Migrations
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("LayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
@ -198,6 +204,9 @@ namespace API.Data.Migrations
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("ShowScreenHints")
.HasColumnType("INTEGER");
b.Property<int?>("ThemeId")
.HasColumnType("INTEGER");

View File

@ -25,12 +25,23 @@ namespace API.Entities
/// </example>
/// </summary>
public ReaderMode ReaderMode { get; set; }
/// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
/// </summary>
public bool AutoCloseMenu { get; set; } = true;
/// <summary>
/// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
/// </summary>
public bool ShowScreenHints { get; set; } = true;
/// <summary>
/// Manga Reader Option: How many pages to display in the reader at once
/// </summary>
public LayoutMode LayoutMode { get; set; } = LayoutMode.Single;
/// <summary>
/// Manga Reader Option: Background color of the reader
/// </summary>
public string BackgroundColor { get; set; } = "#000000";
/// <summary>
/// Book Reader Option: Should the background color be dark
/// </summary>
public bool BookReaderDarkMode { get; set; } = true;

View File

@ -0,0 +1,11 @@
using System.ComponentModel;
namespace API.Entities.Enums;
public enum LayoutMode
{
[Description("Single")]
Single = 1,
[Description("Double")]
Double = 2
}

View File

@ -5,13 +5,10 @@ namespace API.Entities.Enums
public enum ReaderMode
{
[Description("Left and Right")]
// ReSharper disable once InconsistentNaming
MANGA_LR = 0,
LeftRight = 0,
[Description("Up and Down")]
// ReSharper disable once InconsistentNaming
MANGA_UP = 1,
UpDown = 1,
[Description("Webtoon")]
// ReSharper disable once InconsistentNaming
WEBTOON = 2
Webtoon = 2
}
}

View File

@ -19,12 +19,13 @@ namespace API.Extensions
/// <returns>Sorted Enumerable</returns>
public static IEnumerable<T> OrderByNatural<T>(this IEnumerable<T> items, Func<T, string> selector, StringComparer stringComparer = null)
{
var maxDigits = items
var list = items.ToList();
var maxDigits = list
.SelectMany(i => Regex.Matches(selector(i))
.Select(digitChunk => (int?)digitChunk.Value.Length))
.Max() ?? 0;
return items.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture);
return list.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture);
}
}
}

View File

@ -27,7 +27,6 @@ namespace API.Services
ArchiveLibrary CanOpen(string archivePath);
bool ArchiveNeedsFlattening(ZipArchive archive);
Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder);
string FindCoverImageFilename(string archivePath, IList<string> entryNames);
}
/// <summary>
@ -127,8 +126,8 @@ namespace API.Services
public static string FindFolderEntry(IEnumerable<string> entryFullNames)
{
var result = entryFullNames
.OrderByNatural(Path.GetFileNameWithoutExtension)
.Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)))
.OrderByNatural(Path.GetFileNameWithoutExtension)
.FirstOrDefault(Parser.Parser.IsCoverImage);
return string.IsNullOrEmpty(result) ? null : result;
@ -144,8 +143,8 @@ namespace API.Services
// First check if there are any files that are not in a nested folder before just comparing by filename. This is needed
// because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg.
var fullNames = entryFullNames
.OrderByNatural(c => c.GetFullPathWithoutExtension())
.Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)) && Parser.Parser.IsImage(path))
.OrderByNatural(c => c.GetFullPathWithoutExtension())
.ToList();
if (fullNames.Count == 0) return null;
@ -201,9 +200,8 @@ namespace API.Services
case ArchiveLibrary.Default:
{
using var archive = ZipFile.OpenRead(archivePath);
var entryNames = archive.Entries.Select(e => e.FullName).ToList();
var entryName = FindCoverImageFilename(archivePath, entryNames);
var entryName = FindCoverImageFilename(archivePath, archive.Entries.Select(e => e.FullName));
var entry = archive.Entries.Single(e => e.FullName == entryName);
using var stream = entry.Open();
@ -242,7 +240,7 @@ namespace API.Services
/// <param name="archivePath"></param>
/// <param name="entryNames"></param>
/// <returns></returns>
public string FindCoverImageFilename(string archivePath, IList<string> entryNames)
public static string FindCoverImageFilename(string archivePath, IEnumerable<string> entryNames)
{
var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath));
return entryName;

View File

@ -79,6 +79,12 @@ public class SeriesService : ISeriesService
series.Metadata.SummaryLocked = true;
}
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata.Language)
{
series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language;
series.Metadata.LanguageLocked = true;
}
series.Metadata.CollectionTags ??= new List<CollectionTag>();
UpdateRelatedList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) =>

View File

@ -9173,6 +9173,14 @@
"tslib": "^2.0.0"
}
},
"ngx-color-picker": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-12.0.0.tgz",
"integrity": "sha512-SY5KoZka/uq2MNhUAKfJXQjjS2TFvKDJHbsCxfnjKjS/VHx8VVeTJpnt5wuuewzRzLxfOm5y2Fw8/HTPEPtRkA==",
"requires": {
"tslib": "^2.3.0"
}
},
"ngx-file-drop": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-13.0.0.tgz",

View File

@ -38,6 +38,7 @@
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.6.0",
"ngx-color-picker": "^12.0.0",
"ngx-file-drop": "^13.0.0",
"ngx-toastr": "^14.2.1",
"rxjs": "~7.5.4",

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { MemberService } from '../_services/member.service';
@Injectable({
@ -12,6 +12,7 @@ export class LibraryAccessGuard implements CanActivate {
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const libraryId = parseInt(state.url.split('library/')[1], 10);
if (isNaN(libraryId)) return of(false);
return this.memberService.hasLibraryAccess(libraryId);
}
}

View File

@ -1,6 +1,7 @@
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
import { PageSplitOption } from './page-split-option';
import { READER_MODE } from './reader-mode';
import { ReaderMode } from './reader-mode';
import { ReadingDirection } from './reading-direction';
import { ScalingOption } from './scaling-option';
import { SiteTheme } from './site-theme';
@ -10,8 +11,11 @@ export interface Preferences {
readingDirection: ReadingDirection;
scalingOption: ScalingOption;
pageSplitOption: PageSplitOption;
readerMode: READER_MODE;
readerMode: ReaderMode;
autoCloseMenu: boolean;
layoutMode: LayoutMode;
backgroundColor: string;
showScreenHints: boolean;
// Book Reader
bookReaderDarkMode: boolean;
@ -29,4 +33,5 @@ export interface Preferences {
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}];
export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.FitSplit}, {text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}];
export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}];
export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}];
export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}];

View File

@ -1,14 +1,17 @@
export enum READER_MODE {
/**
* The pagination method used by the reader
*/
export enum ReaderMode {
/**
* Manga default left/right to page
*/
MANGA_LR = 0,
LeftRight = 0,
/**
* Manga up and down to page
*/
MANGA_UD = 1,
UpDown = 1,
/**
* Webtoon reading (scroll) with optional areas to tap
*/
WEBTOON = 2
Webtoon = 2
}

View File

@ -1,3 +1,6 @@
/**
* Direction the user is reading. Maps to the pagination method. Not applicable with ReaderMode.Webtoon
*/
export enum ReadingDirection {
LeftToRight = 0,
RightToLeft = 1

View File

@ -1,6 +1,21 @@
/**
* How the image should scale to the screen size
*/
export enum ScalingOption {
/**
* Fit the image into the height of screen
*/
FitToHeight = 0,
/**
* Fit the image into the width of screen
*/
FitToWidth = 1,
/**
* Apply no logic and render the image as is
*/
Original = 2,
/**
* Ask the reader to attempt to choose the best ScalingOption for the user
*/
Automatic = 3
}

View File

@ -162,6 +162,20 @@ export class AccountService implements OnDestroy {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
/**
* This will get latest preferences for a user and cache them into user store
* @returns
*/
getPreferences() {
return this.httpClient.get<Preferences>(this.baseUrl + 'users/get-preferences').pipe(map(pref => {
if (this.currentUser !== undefined || this.currentUser != null) {
this.currentUser.preferences = pref;
this.setCurrentUser(this.currentUser);
}
return pref;
}), takeUntil(this.onDestroy));
}
updatePreferences(userPreferences: Preferences) {
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
if (this.currentUser !== undefined || this.currentUser != null) {

View File

@ -114,11 +114,11 @@ export class ReaderService {
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/
setOverrideStyles() {
setOverrideStyles(backgroundColor: string = 'black') {
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
this.originalBodyColor = bodyNode.style.background;
bodyNode.setAttribute('style', 'background-color: black !important');
bodyNode.setAttribute('style', 'background-color: ' + backgroundColor + ' !important');
}
}

View File

@ -15,6 +15,7 @@ import { ThemeTestComponent } from './theme-test/theme-test.component';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
const routes: Routes = [
{path: '', component: UserLoginComponent},
{
path: 'admin',
canActivate: [AdminGuard],
@ -69,8 +70,7 @@ const routes: Routes = [
]
},
{path: 'theme', component: ThemeTestComponent},
{path: '', component: UserLoginComponent},
{path: 'login', component: UserLoginComponent}, // TODO: move this to registration module
{path: '**', component: UserLoginComponent, pathMatch: 'full'}
];

View File

@ -8,7 +8,6 @@ import { NavService } from './_services/nav.service';
import { filter } from 'rxjs/operators';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { DOCUMENT } from '@angular/common';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-root',
@ -59,7 +58,7 @@ export class AppComponent implements OnInit {
if (user) {
this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user));
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
}
}
}
setDocHeight() {

View File

@ -35,6 +35,7 @@ import { RegistrationModule } from './registration/registration.module';
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
import { ThemeTestComponent } from './theme-test/theme-test.component';
import { PipeModule } from './pipe/pipe.module';
import { ColorPickerModule } from 'ngx-color-picker';
@NgModule({
@ -79,6 +80,8 @@ import { PipeModule } from './pipe/pipe.module';
ReadingListModule,
RegistrationModule,
ColorPickerModule, // User preferences
NgbAccordionModule, // ThemeTest Component only
PipeModule,

View File

@ -7,7 +7,6 @@ import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Series } from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-bookmarks-modal',
@ -28,7 +27,7 @@ export class BookmarksModalComponent implements OnInit {
constructor(public imageService: ImageService, private readerService: ReaderService,
public modal: NgbActiveModal, private downloadService: DownloadService,
private toastr: ToastrService, private seriesService: SeriesService) { }
private toastr: ToastrService) { }
ngOnInit(): void {
this.init();

View File

@ -112,7 +112,7 @@
</div>
<div class="row g-0">
<div class="col-md-4 pe-2">
<div class="col-md-4 col-sm-12 pe-2">
<div class="mb-3">
<label for="language" class="form-label">Language</label>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
@ -127,7 +127,7 @@
</app-typeahead>
</div>
</div>
<div class="col-md-4 pe-2">
<div class="col-md-4 col-sm-12 pe-2">
<div class="mb-3">
<label for="age-rating" class="form-label">Age Rating</label>
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
@ -138,7 +138,7 @@
</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-4 col-sm-12">
<div class="mb-3">
<label for="publication-status" class="form-label">Publication Status</label>
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">

View File

@ -160,13 +160,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.ageRating = parseInt(val + '', 10);
if (!this.editSeriesForm.get('ageRating')?.touched) return;
this.metadata.ageRatingLocked = true;
});
this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.publicationStatus = parseInt(val + '', 10);
if (!this.editSeriesForm.get('publicationStatus')?.touched) return;
this.metadata.publicationStatusLocked = true;
});
}

View File

@ -0,0 +1,14 @@
/**
* How to layout pages for reading
*/
export enum LayoutMode {
/**
* Renders a single page on the renderer. Cover images will follow splitting logic.
*/
Single = 1,
/**
* Renders 2 pages side by side on the renderer. Cover images will not split and take up both panes.
*/
Double = 2,
}

View File

@ -50,4 +50,4 @@
50% {
filter: opacity(0.25);
}
}
}

View File

@ -12,7 +12,13 @@
{{subtitle}}
</div>
</div>
<div style="margin-left: auto; padding-right: 3%;">
<button class="btn btn-icon btn-small" title="Shortcuts" (click)="openShortcutModal()">
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
<span class="visually-hidden">Keyboard Shortcuts Modal</span>
</button>
<button class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="pageBookmarked" title="{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{pageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="visually-hidden">{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
</div>
</div>
@ -23,37 +29,49 @@
</div>
</ng-container>
<div (click)="toggleMenu()" class="reading-area">
<!-- TODO: Change this logic to only render for split pages and use image for all else-->
<canvas style="display: none;" #content class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}"
ondragstart="return false;" onselectstart="return false;">
</canvas>
<div (click)="toggleMenu()" class="reading-area" [ngStyle]="{'background-color': backgroundColor}">
<ng-container *ngIf="readerMode !== ReaderMode.Webtoon">
<div [ngClass]="{'d-none': !renderWithCanvas }">
<canvas #content class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}}"
ondragstart="return false;" onselectstart="return false;">
</canvas>
</div>
<div [ngClass]="{'d-none': renderWithCanvas, 'center-double': layoutMode === LayoutMode.Double && !isCoverImage(), 'fit-to-height-double-offset': this.generalSettingsForm.get('fittingOption')?.value === FITTING_OPTION.HEIGHT && layoutMode === LayoutMode.Double && !isCoverImage() && utilityService.getActiveBreakpoint() >= Breakpoint.Tablet}">
<img [src]="canvasImage.src" id="image-1"
class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
<ng-container *ngIf="layoutMode === LayoutMode.Double && !isCoverImage()">
<img [src]="canvasImage2.src" id="image-2" class="image-2 {{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
</ng-container>
</div>
</ng-container>
<ng-container *ngIf="readerMode === ReaderMode.Webtoon">
<div class="webtoon-images" *ngIf="readerMode === ReaderMode.Webtoon && !isLoading && !inSetup">
<app-infinite-scroller [pageNum]="pageNum"
[bufferPages]="5"
[goToPage]="goToPageEvent"
(pageNumberChange)="handleWebtoonPageChange($event)"
[totalPages]="maxPages"
[urlProvider]="getPageUrl"
(loadNextChapter)="loadNextChapter()"
(loadPrevChapter)="loadPrevChapter()"
[bookmarkPage]="showBookmarkEffectEvent"
[fullscreenToggled]="fullscreenEvent"></app-infinite-scroller>
</div>
</ng-container>
<div *ngIf="isCoverImage && shouldRenderAsFitSplit()">
<img [src]="canvasImage.src" class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
</div>
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading && !inSetup">
<app-infinite-scroller [pageNum]="pageNum"
[bufferPages]="5"
[goToPage]="goToPageEvent"
(pageNumberChange)="handleWebtoonPageChange($event)"
[totalPages]="maxPages"
[urlProvider]="getPageUrl"
(loadNextChapter)="loadNextChapter()"
(loadPrevChapter)="loadPrevChapter()"
[bookmarkPage]="showBookmarkEffectEvent"
[fullscreenToggled]="fullscreenEvent"></app-infinite-scroller>
</div>
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
<div class="pagination-area {{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')">
<ng-container *ngIf="readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown">
<div class="pagination-area {{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')">
<div *ngIf="showClickOverlay">
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'down'}}"
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'right' : 'down'}}"
title="Next Page" aria-hidden="true"></i>
</div>
</div>
<div class="pagination-area {{readerMode === READER_MODE.MANGA_LR ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')">
<div class="pagination-area {{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')">
<div *ngIf="showClickOverlay">
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === READER_MODE.MANGA_LR ? 'left' : 'up'}}"
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}"
title="Previous Page" aria-hidden="true"></i>
</div>
</div>
@ -82,7 +100,7 @@
</div>
<div class="row pt-4 ms-2 me-2">
<div class="col">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === READER_MODE.WEBTOON || readerMode === READER_MODE.MANGA_UD" aria-describedby="reading-direction" title="Reading Direction: {{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" title="Reading Direction: {{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
<span id="reading-direction" class="visually-hidden">{{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
</button>
@ -142,11 +160,29 @@
<label for="autoCloseMenu" class="form-check-label">Auto Close Menu</label>
</div>
<div class="col-6">
<div class="form-check">
<input id="autoCloseMenu" type="checkbox" aria-label="Admin" class="form-check-input" formControlName="autoCloseMenu">
<div class="mb-3">
<label id="auto-close-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-2 mb-2">
<div class="col-6">
<label for="layout-mode" class="form-label">Layout Mode</label>
</div>
<div class="col-6">
<select class="form-control" id="page-fitting" formControlName="layoutMode">
<option value="1">Single</option>
<option value="2">Double</option>
</select>
</div>
</div>
</form>
</div>
</div>

View File

@ -14,10 +14,6 @@ $pointer-offset: 5px;
}
}
// .btn-icon {
// color: white;
// }
canvas {
position: absolute;
}
@ -32,9 +28,6 @@ canvas {
}
.loading {
position: absolute;
left: 48%;
@ -62,14 +55,21 @@ canvas {
}
// Fitting Options
// .full-height {
// position: absolute;
// margin: auto;
// top: 0px;
// left: 0;
// right: 0;
// bottom: 0px;
// height: 100%;
// }
.full-height {
position: absolute;
margin: auto;
top: 0px;
left: 0;
right: 0;
bottom: 0px;
height: 100%;
width: auto;
margin: 0 auto;
height: 100vh;
vertical-align: top;
}
.original {
@ -84,8 +84,29 @@ canvas {
.full-width {
width: 100%;
&.double {
width: 50%
}
.image-2 {
margin-left: 50%;
}
}
.center-double {
display: block;
margin-left: auto;
margin-right: auto;
}
.fit-to-height-double-offset {
width: 50%;
}
.right {
position: fixed;
@ -255,4 +276,4 @@ canvas {
50% {
border: 5px solid var(--primary-color);
}
}
}

View File

@ -12,7 +12,7 @@ import { ScalingOption } from '../_models/preferences/scaling-option';
import { PageSplitOption } from '../_models/preferences/page-split-option';
import { BehaviorSubject, forkJoin, ReplaySubject, Subject } from 'rxjs';
import { ToastrService } from 'ngx-toastr';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { Breakpoint, KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { CircularArray } from '../shared/data-structures/circular-array';
import { MemberService } from '../_services/member.service';
import { Stack } from '../shared/data-structures/stack';
@ -21,12 +21,15 @@ import { trigger, state, style, transition, animate } from '@angular/animations'
import { ChapterInfo } from './_models/chapter-info';
import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
import { pageSplitOptions, scalingOptions } from '../_models/preferences/preferences';
import { READER_MODE } from '../_models/preferences/reader-mode';
import { ReaderMode } from '../_models/preferences/reader-mode';
import { MangaFormat } from '../_models/manga-format';
import { LibraryService } from '../_services/library.service';
import { LibraryType } from '../_models/library';
import { ShorcutsModalComponent } from '../reader-shared/_modals/shorcuts-modal/shorcuts-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { LayoutMode } from './_models/layout-mode';
const PREFETCH_PAGES = 5;
const PREFETCH_PAGES = 8;
const CHAPTER_ID_NOT_FETCHED = -2;
const CHAPTER_ID_DOESNT_EXIST = -1;
@ -101,7 +104,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
isFullscreen: boolean = false;
autoCloseMenu: boolean = true;
readerMode: READER_MODE = READER_MODE.MANGA_LR;
readerMode: ReaderMode = ReaderMode.LeftRight;
pageSplitOptions = pageSplitOptions;
@ -110,7 +113,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('reader') reader!: ElementRef;
@ViewChild('content') canvas: ElementRef | undefined;
private ctx!: CanvasRenderingContext2D;
canvasImage = new Image(); // private
/**
* Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer
*/
canvasImage = new Image();
/**
* Used soley for LayoutMode.Double rendering. Will always hold the next image in buffer.
*/
canvasImage2 = new Image();
renderWithCanvas: boolean = false; // Dictates if we use render with canvas or with image
/**
* A circular array of size PREFETCH_PAGES + 2. Maintains prefetched Images around the current page to load from to avoid loading animation.
@ -140,6 +151,10 @@ 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.
*/
@ -226,6 +241,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* Used for webtoon reader. When loading pages or data, this will disable the reader
*/
inSetup: boolean = true;
/**
* If we render 2 pages at once or 1
*/
layoutMode: LayoutMode = LayoutMode.Single;
/**
* Background color for canvas/reader. User configured.
*/
backgroundColor: string = '#FFFFFF';
private readonly onDestroy = new Subject<void>();
@ -233,7 +257,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
getPageUrl = (pageNum: number) => this.readerService.getPageUrl(this.chapterId, pageNum);
get pageBookmarked() {
return this.bookmarks.hasOwnProperty(this.pageNum);
}
@ -250,33 +273,46 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
get readerModeIcon() {
switch(this.readerMode) {
case READER_MODE.MANGA_LR:
case ReaderMode.LeftRight:
return 'fa-exchange-alt';
case READER_MODE.MANGA_UD:
case ReaderMode.UpDown:
return 'fa-exchange-alt fa-rotate-90';
case READER_MODE.WEBTOON:
case ReaderMode.Webtoon:
return 'fa-arrows-alt-v';
default:
return '';
}
}
get READER_MODE(): typeof READER_MODE {
return READER_MODE;
get ReaderMode() {
return ReaderMode;
}
get LayoutMode() {
return LayoutMode;
}
get ReadingDirection(): typeof ReadingDirection {
get ReadingDirection() {
return ReadingDirection;
}
get PageSplitOption(): typeof PageSplitOption {
get PageSplitOption() {
return PageSplitOption;
}
get Breakpoint() {
return Breakpoint;
}
get FITTING_OPTION() {
return FITTING_OPTION;
}
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
public readerService: ReaderService, private location: Location,
private formBuilder: FormBuilder, private navService: NavService,
private toastr: ToastrService, private memberService: MemberService,
private libraryService: LibraryService, private utilityService: UtilityService,
private renderer: Renderer2, @Inject(DOCUMENT) private document: Document) {
private libraryService: LibraryService, public utilityService: UtilityService,
private renderer: Renderer2, @Inject(DOCUMENT) private document: Document, private modalService: NgbModal) {
this.navService.hideNavBar();
}
@ -304,8 +340,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.continuousChaptersStack.push(this.chapterId);
this.readerService.setOverrideStyles();
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.user = user;
@ -314,16 +348,28 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.pageSplitOption = this.user.preferences.pageSplitOption;
this.autoCloseMenu = this.user.preferences.autoCloseMenu;
this.readerMode = this.user.preferences.readerMode;
this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single;
this.backgroundColor = this.user.preferences.backgroundColor || '#000000';
this.readerService.setOverrideStyles(this.backgroundColor);
this.generalSettingsForm = this.formBuilder.group({
autoCloseMenu: this.autoCloseMenu,
pageSplitOption: this.pageSplitOption,
fittingOption: this.translateScalingOption(this.scalingOption)
fittingOption: this.translateScalingOption(this.scalingOption),
layoutMode: this.layoutMode
});
this.updateForm();
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.layoutMode = parseInt(val, 10);
if (this.layoutMode === LayoutMode.Double) {
// Update canvasImage2
this.canvasImage2 = this.cachedImages.next();
}
});
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
@ -370,17 +416,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@HostListener('window:keyup', ['$event'])
handleKeyPress(event: KeyboardEvent) {
switch (this.readerMode) {
case READER_MODE.MANGA_LR:
case ReaderMode.LeftRight:
if (event.key === KEY_CODES.RIGHT_ARROW) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
} else if (event.key === KEY_CODES.LEFT_ARROW) {
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
}
break;
case READER_MODE.MANGA_UD:
case READER_MODE.WEBTOON:
case ReaderMode.UpDown:
case ReaderMode.Webtoon:
if (event.key === KEY_CODES.DOWN_ARROW) {
this.nextPage()
} else if (event.key === KEY_CODES.UP_ARROW) {
@ -514,7 +559,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
render() {
if (this.readerMode === READER_MODE.WEBTOON) {
if (this.readerMode === ReaderMode.Webtoon) {
this.isLoading = false;
} else {
this.loadPage();
@ -535,6 +580,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.title += ' - ' + chapterInfo.chapterTitle;
}
// TODO: Move this to the backend
this.subtitle = '';
if (chapterInfo.isSpecial && chapterInfo.volumeNumber === '0') {
this.subtitle = chapterInfo.fileName;
@ -581,10 +627,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
getFittingOptionClass() {
const formControl = this.generalSettingsForm.get('fittingOption');
let val = FITTING_OPTION.HEIGHT;
if (formControl === undefined) {
return FITTING_OPTION.HEIGHT;
val = FITTING_OPTION.HEIGHT;
}
return formControl?.value;
val = formControl?.value;
if (this.isCoverImage() && this.shouldRenderAsFitSplit()) {
// Rewriting to fit to width for this cover image
val = FITTING_OPTION.WIDTH;
}
if (!this.isCoverImage() && this.layoutMode === LayoutMode.Double) {
return val + ' double';
}
return val;
}
getFittingIcon() {
@ -703,7 +760,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
handlePageChange(event: any, direction: string) {
if (this.readerMode === READER_MODE.WEBTOON) {
if (this.readerMode === ReaderMode.Webtoon) {
if (direction === 'right') {
this.nextPage(event);
} else {
@ -738,12 +795,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.pagingDirection = PAGING_DIRECTION.FORWARD;
if (this.isNoSplit() || notInSplit) {
this.setPageNum(this.pageNum + 1);
if (this.readerMode !== READER_MODE.WEBTOON) {
if (this.readerMode !== ReaderMode.Webtoon) {
this.canvasImage = this.cachedImages.next();
this.canvasImage2 = this.cachedImages.peek(2);
console.log('[nextPage] canvasImage: ', this.canvasImage);
console.log('[nextPage] canvasImage2: ', this.canvasImage2);
}
}
if (this.readerMode !== READER_MODE.WEBTOON) {
if (this.readerMode !== ReaderMode.Webtoon) {
this.loadPage();
}
}
@ -769,9 +829,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.isNoSplit() || notInSplit) {
this.setPageNum(this.pageNum - 1);
this.canvasImage = this.cachedImages.prev();
this.canvasImage2 = this.cachedImages.peek(-2);
console.log('[prevPage] canvasImage: ', this.canvasImage);
console.log('[prevPage] canvasImage2: ', this.canvasImage2);
}
if (this.readerMode !== READER_MODE.WEBTOON) {
if (this.readerMode !== ReaderMode.Webtoon) {
this.loadPage();
}
}
@ -877,69 +940,19 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const needsSplitting = this.isCoverImage();
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.renderWithCanvas = true;
console.log('[Render] Canvas')
} 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.renderWithCanvas = true;
console.log('[Render] Canvas')
} else {
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
this.updateScalingForFirstPageRender();
}
// Fit Split on a page that needs splitting
if (!this.shouldRenderAsFitSplit()) {
this.setCanvasSize();
this.ctx.drawImage(this.canvasImage, 0, 0);
// Reset scroll on non HEIGHT Fits
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
window.scrollTo(0, 0);
}
this.isLoading = false;
return;
}
const windowWidth = window.innerWidth
|| document.documentElement.clientWidth
|| document.body.clientWidth;
const windowHeight = window.innerHeight
|| document.documentElement.clientHeight
|| document.body.clientHeight;
// If the user's screen is wider than the image, just pretend this is no split, as it will render nicer
this.canvas.nativeElement.width = windowWidth;
this.canvas.nativeElement.height = windowHeight;
const ratio = this.canvasImage.width / this.canvasImage.height;
let newWidth = windowWidth;
let newHeight = newWidth / ratio;
if (newHeight > windowHeight) {
newHeight = windowHeight;
newWidth = newHeight * ratio;
}
// Optimization: When the screen is larger than newWidth, allow no split rendering to occur for a better fit
// if (windowWidth > newWidth) {
// this.setCanvasSize();
// this.ctx.drawImage(this.canvasImage, 0, 0);
// } else {
// this.setCanvasSize();
// //this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
// this.ctx.drawImage(this.canvasImage, 0, 0, newWidth, newHeight);
// }
this.setCanvasSize();
// var offScreenCanvas = document.createElement('canvas')
// offScreenCanvas.width = newWidth;
// offScreenCanvas.height = newHeight;
// const resizedImage = new Image();
// pica.resize(this.canvasImage, offScreenCanvas);
//this.document.querySelector('.reading-area')?.appendChild(this.canvasImage);
this.renderWithCanvas = false;
}
// Reset scroll on non HEIGHT Fits
@ -984,7 +997,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
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;
//if (!this.isCoverImage() || parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false;
return true;
}
@ -1011,9 +1023,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLoading = true;
this.canvasImage = this.cachedImages.current();
this.canvasImage2 = this.cachedImages.next(); // TODO: Do I need this here?
console.log('[loadPage] canvasImage: ', this.canvasImage);
console.log('[loadPage] canvasImage2: ', this.canvasImage2);
if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) {
this.canvasImage.src = this.readerService.getPageUrl(this.chapterId, this.pageNum);
this.canvasImage2.src = this.readerService.getPageUrl(this.chapterId, this.pageNum + 1); // TODO: I need to handle last page correctly
this.canvasImage.onload = () => this.renderPage();
console.log('[loadPage] (after setting) canvasImage: ', this.canvasImage);
console.log('[loadPage] (after setting) canvasImage2: ', this.canvasImage2);
} else {
this.renderPage();
}
@ -1027,7 +1046,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.readingDirection = ReadingDirection.LeftToRight;
}
if (this.menuOpen) {
if (this.menuOpen && this.user.preferences.showScreenHints) {
this.showClickOverlay = true;
setTimeout(() => {
this.showClickOverlay = false;
@ -1039,7 +1058,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
sliderDragUpdate(context: ChangeContext) {
// This will update the value for value except when in webtoon due to how the webtoon reader
// responds to page changes
if (this.readerMode !== READER_MODE.WEBTOON) {
if (this.readerMode !== ReaderMode.Webtoon) {
this.setPageNum(context.value);
}
}
@ -1142,15 +1161,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
toggleReaderMode() {
switch(this.readerMode) {
case READER_MODE.MANGA_LR:
this.readerMode = READER_MODE.MANGA_UD;
case ReaderMode.LeftRight:
this.readerMode = ReaderMode.UpDown;
this.pagingDirection = PAGING_DIRECTION.FORWARD;
break;
case READER_MODE.MANGA_UD:
this.readerMode = READER_MODE.WEBTOON;
case ReaderMode.UpDown:
this.readerMode = ReaderMode.Webtoon;
break;
case READER_MODE.WEBTOON:
this.readerMode = READER_MODE.MANGA_LR;
case ReaderMode.Webtoon:
this.readerMode = ReaderMode.LeftRight;
break;
}
@ -1160,7 +1179,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
updateForm() {
if ( this.readerMode === READER_MODE.WEBTOON) {
if ( this.readerMode === ReaderMode.Webtoon) {
this.generalSettingsForm.get('fittingOption')?.disable()
this.generalSettingsForm.get('pageSplitOption')?.disable();
} else {
@ -1178,23 +1197,45 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
bookmarkPage() {
const pageNum = this.pageNum;
// TODO: Handle LayoutMode.Double
if (this.pageBookmarked) {
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
if (this.layoutMode === LayoutMode.Double) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
forkJoin(apis).pipe(take(1)).subscribe(() => {
delete this.bookmarks[pageNum];
});
} else {
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
let apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
if (this.layoutMode === LayoutMode.Double) apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
forkJoin(apis).pipe(take(1)).subscribe(() => {
this.bookmarks[pageNum] = 1;
});
}
// Show an effect on the image to show that it was bookmarked
this.showBookmarkEffectEvent.next(pageNum);
if (this.readerMode != READER_MODE.WEBTOON) {
if (this.canvas) {
this.renderer.addClass(this.canvas?.nativeElement, 'bookmark-effect');
if (this.readerMode != ReaderMode.Webtoon) {
let elements:Array<Element | ElementRef> = [];
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.Double) {
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(() => {
this.renderer.removeClass(this.canvas?.nativeElement, 'bookmark-effect');
elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect'));
}, 1000);
}
}
@ -1220,4 +1261,18 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|| document.body.clientHeight;
return [windowWidth, windowHeight];
}
openShortcutModal() {
let ref = this.modalService.open(ShorcutsModalComponent, { scrollable: true, size: 'md' });
ref.componentInstance.shortcuts = [
{key: '⇽', description: 'Move to previous page'},
{key: '⇾', description: 'Move to next page'},
{key: '↑', description: 'Move to previous page'},
{key: '↓', description: 'Move to previous page'},
{key: 'G', description: 'Open Go to Page dialog'},
{key: 'B', description: 'Bookmark current page'},
{key: 'ESC', description: 'Close reader'},
{key: 'SPACE', description: 'Toggle Menu'},
];
}
}

View File

@ -7,6 +7,7 @@ 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 { ReaderSharedModule } from '../reader-shared/reader-shared.module';
@NgModule({
declarations: [
@ -22,6 +23,7 @@ import { InfiniteScrollerComponent } from './infinite-scroller/infinite-scroller
NgbDropdownModule,
NgxSliderModule,
SharedModule,
ReaderSharedModule,
],
exports: [
MangaReaderComponent

View File

@ -1,8 +1,9 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterPipe } from './filter.pipe';
import { PersonRolePipe } from './person-role.pipe';
import { PublicationStatusPipe } from './publication-status.pipe';
import { SentenceCasePipe } from './sentence-case.pipe';
import { PersonRolePipe } from './person-role.pipe';
@ -10,7 +11,8 @@ import { PublicationStatusPipe } from './publication-status.pipe';
declarations: [
FilterPipe,
PersonRolePipe,
PublicationStatusPipe
PublicationStatusPipe,
SentenceCasePipe
],
imports: [
CommonModule,
@ -18,7 +20,8 @@ import { PublicationStatusPipe } from './publication-status.pipe';
exports: [
FilterPipe,
PersonRolePipe,
PublicationStatusPipe
PublicationStatusPipe,
SentenceCasePipe
]
})
export class PipeModule { }

View File

@ -0,0 +1,14 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Keyboard Shortcuts</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="row g-0">
<div class="col-md-6 mb-2" *ngFor="let shortcut of shortcuts">
<span><code>{{shortcut.key}}</code> {{shortcut.description}}</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">Close</button>
</div>

View File

@ -0,0 +1,34 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
export interface KeyboardShortcut {
/**
* String representing key or key combo. Should use + for combos. Will render as upper case
*/
key: string;
/**
* Description of how it works
*/
description: string;
}
@Component({
selector: 'app-shorcuts-modal',
templateUrl: './shorcuts-modal.component.html',
styleUrls: ['./shorcuts-modal.component.scss']
})
export class ShorcutsModalComponent implements OnInit {
@Input() shortcuts: Array<KeyboardShortcut> = [];
constructor(public modal: NgbActiveModal) { }
ngOnInit(): void {
}
close() {
this.modal.close();
}
}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ShorcutsModalComponent } from './_modals/shorcuts-modal/shorcuts-modal.component';
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
@NgModule({
declarations: [
ShorcutsModalComponent
],
imports: [
CommonModule,
NgbModalModule
],
exports: [
ShorcutsModalComponent
]
})
export class ReaderSharedModule { }

View File

@ -522,7 +522,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
openEditSeriesModal() {
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'xl' });
modalRef.componentInstance.series = this.series;
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
window.scrollTo(0, 0);

View File

@ -14,7 +14,6 @@ import { SeriesFormatComponent } from './series-format/series-format.component';
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
import { CircularLoaderComponent } from './circular-loader/circular-loader.component';
import { NgCircleProgressModule } from 'ng-circle-progress';
import { SentenceCasePipe } from './sentence-case.pipe';
import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { BadgeExpanderComponent } from './badge-expander/badge-expander.component';
import { ImageComponent } from './image/image.component';
@ -31,7 +30,6 @@ import { ImageComponent } from './image/image.component';
SeriesFormatComponent,
UpdateNotificationModalComponent,
CircularLoaderComponent,
SentenceCasePipe,
PersonBadgeComponent,
BadgeExpanderComponent,
ImageComponent
@ -46,7 +44,6 @@ import { ImageComponent } from './image/image.component';
],
exports: [
SafeHtmlPipe, // Used globally
SentenceCasePipe, // Used globablly
ReadMoreComponent, // Used globably
DrawerComponent, // Can be replaced with boostrap offscreen canvas (v5)
ShowIfScrollbarDirective, // Used book reader only?

View File

@ -5,7 +5,7 @@
<span class="visually-hidden">Field is locked</span>
</span>
</ng-container>
<div class="typeahead-input" [ngStyle]="{'width': (settings.showLocked ? '93%' : '100%')}" (click)="onInputFocus($event)">
<div class="typeahead-input" (click)="onInputFocus($event)">
<app-tag-badge *ngFor="let option of optionSelection.selected(); let i = index">
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i }"></ng-container>
<i class="fa fa-times" (click)="toggleSelection(option)" tabindex="0" aria-label="close"></i>

View File

@ -94,14 +94,49 @@
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-layoutmode-option" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>Render a single image to the screen to two side-by-side images</ng-template>
<span class="visually-hidden" id="settings-layoutmode-option-help"><ng-container [ngTemplateOutlet]="layoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="manga-header" formControlName="layoutMode" id="settings-layoutmode-option">
<option *ngFor="let opt of layoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-backgroundcolor-option" class="form-label">Background Color</label>
<input [value]="user.preferences.backgroundColor"
class="form-control"
(colorPickerChange)="settingsForm.markAsTouched()"
[style.background]="user.preferences.backgroundColor"
[cpAlphaChannel]="'disabled'"
[(colorPicker)]="user.preferences.backgroundColor"/>
</div>
</div>
<div class="mb-3">
<label id="auto-close-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3">
<label id="auto-close-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3">
<label id="show-screen-hints-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="show-screen-hints" formControlName="showScreenHints" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="show-screen-hints">Show Screen Hints</label>
</div>
</div>
</div>
</div>
</div>

View File

@ -5,12 +5,13 @@ import { take } from 'rxjs/operators';
import { Options } from '@angular-slider/ngx-slider';
import { Title } from '@angular/platform-browser';
import { BookService } from 'src/app/book-reader/book.service';
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences } from 'src/app/_models/preferences/preferences';
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, layoutModes } from 'src/app/_models/preferences/preferences';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { SettingsService } from 'src/app/admin/settings.service';
import { forkJoin } from 'rxjs';
@Component({
selector: 'app-user-preferences',
@ -23,6 +24,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
scalingOptions = scalingOptions;
pageSplitOptions = pageSplitOptions;
readingModes = readingModes;
layoutModes = layoutModes;
settingsForm: FormGroup = new FormGroup({});
passwordChangeForm: FormGroup = new FormGroup({});
@ -63,8 +65,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
opdsEnabled: boolean = false;
makeUrl: (val: string) => string = (val: string) => {return this.transformKeyToOpdsUrl(val)};
backgroundColor: any; // TODO: Hook into user pref
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService) {
private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
private router: Router) {
this.fontFamilies = this.bookService.getFontFamilies();
this.route.fragment.subscribe(frag => {
@ -83,31 +88,41 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.titleService.setTitle('Kavita - User Preferences');
this.accountService.currentUser$.pipe(take(1)).subscribe((user) => {
if (user) {
this.user = user;
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasChangePasswordRole = this.accountService.hasChangePasswordRole(user);
if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) {
this.user.preferences.bookReaderFontFamily = 'default';
}
this.settingsForm.addControl('readingDirection', new FormControl(user.preferences.readingDirection, []));
this.settingsForm.addControl('scalingOption', new FormControl(user.preferences.scalingOption, []));
this.settingsForm.addControl('pageSplitOption', new FormControl(user.preferences.pageSplitOption, []));
this.settingsForm.addControl('autoCloseMenu', new FormControl(user.preferences.autoCloseMenu, []));
this.settingsForm.addControl('readerMode', new FormControl(user.preferences.readerMode, []));
this.settingsForm.addControl('bookReaderDarkMode', new FormControl(user.preferences.bookReaderDarkMode, []));
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(user.preferences.bookReaderFontFamily, []));
this.settingsForm.addControl('bookReaderFontSize', new FormControl(user.preferences.bookReaderFontSize, []));
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(user.preferences.bookReaderLineSpacing, []));
this.settingsForm.addControl('bookReaderMargin', new FormControl(user.preferences.bookReaderMargin, []));
this.settingsForm.addControl('bookReaderReadingDirection', new FormControl(user.preferences.bookReaderReadingDirection, []));
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(!!user.preferences.bookReaderTapToPaginate, []));
this.settingsForm.addControl('theme', new FormControl(user.preferences.theme, []));
forkJoin({
user: this.accountService.currentUser$.pipe(take(1)),
pref: this.accountService.getPreferences()
}).subscribe(results => {
if (results.user === undefined) {
this.router.navigateByUrl('/login');
return;
}
this.user = results.user;
this.user.preferences = results.pref;
this.isAdmin = this.accountService.hasAdminRole(results.user);
this.hasChangePasswordRole = this.accountService.hasChangePasswordRole(results.user);
if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) {
this.user.preferences.bookReaderFontFamily = 'default';
}
this.settingsForm.addControl('readingDirection', new FormControl(this.user.preferences.readingDirection, []));
this.settingsForm.addControl('scalingOption', new FormControl(this.user.preferences.scalingOption, []));
this.settingsForm.addControl('pageSplitOption', new FormControl(this.user.preferences.pageSplitOption, []));
this.settingsForm.addControl('autoCloseMenu', new FormControl(this.user.preferences.autoCloseMenu, []));
this.settingsForm.addControl('showScreenHints', new FormControl(this.user.preferences.showScreenHints, []));
this.settingsForm.addControl('readerMode', new FormControl(this.user.preferences.readerMode, []));
this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.layoutMode, []));
this.settingsForm.addControl('bookReaderDarkMode', new FormControl(this.user.preferences.bookReaderDarkMode, []));
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, []));
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, []));
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, []));
this.settingsForm.addControl('bookReaderReadingDirection', new FormControl(this.user.preferences.bookReaderReadingDirection, []));
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(!!this.user.preferences.bookReaderTapToPaginate, []));
this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, []));
});
this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required]));
@ -131,7 +146,9 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.get('readingDirection')?.setValue(this.user.preferences.readingDirection);
this.settingsForm.get('scalingOption')?.setValue(this.user.preferences.scalingOption);
this.settingsForm.get('autoCloseMenu')?.setValue(this.user.preferences.autoCloseMenu);
this.settingsForm.get('showScreenHints')?.setValue(this.user.preferences.showScreenHints);
this.settingsForm.get('readerMode')?.setValue(this.user.preferences.readerMode);
this.settingsForm.get('layoutMode')?.setValue(this.user.preferences.layoutMode);
this.settingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption);
this.settingsForm.get('bookReaderDarkMode')?.setValue(this.user.preferences.bookReaderDarkMode);
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily);
@ -157,7 +174,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
scalingOption: parseInt(modelSettings.scalingOption, 10),
pageSplitOption: parseInt(modelSettings.pageSplitOption, 10),
autoCloseMenu: modelSettings.autoCloseMenu,
readerMode: parseInt(modelSettings.readerMode),
readerMode: parseInt(modelSettings.readerMode, 10),
layoutMode: parseInt(modelSettings.layoutMode, 10),
showScreenHints: modelSettings.showScreenHints,
backgroundColor: this.user.preferences.backgroundColor,
bookReaderDarkMode: modelSettings.bookReaderDarkMode,
bookReaderFontFamily: modelSettings.bookReaderFontFamily,
bookReaderLineSpacing: modelSettings.bookReaderLineSpacing,

View File

@ -10,7 +10,8 @@ import { ApiKeyComponent } from './api-key/api-key.component';
import { SharedModule } from '../shared/shared.module';
import { ThemeManagerComponent } from './theme-manager/theme-manager.component';
import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe';
import { PipeModule } from '../pipe/pipe.module';
import { ColorPickerModule } from 'ngx-color-picker';
@ -30,7 +31,9 @@ import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe';
NgbTooltipModule,
NgxSliderModule,
UserSettingsRoutingModule,
SharedModule // SentenceCase pipe
//SharedModule, // SentenceCase pipe
PipeModule,
ColorPickerModule, // User prefernces background color
],
exports: [
SiteThemeProviderPipe