From 3484211132c41dbbc3cc0c89000fbeb4738f0e7c Mon Sep 17 00:00:00 2001 From: CKolle <115696142+CKolle@users.noreply.github.com> Date: Mon, 6 Mar 2023 21:02:29 +0100 Subject: [PATCH] Added vertical reading mode to the book reader (#1787) * Add vertical reading mode support and update API for reading mode preference * Removed dead code, added a fix for scroll end margins in chrome when in vertical mode(book reader). Added back some comments * Added Description()] annotation for the ReadingMode enum, like other enums, and added summary documentation * Added the ability to scroll in vertical writing style without holding down shift. Also renamed the book reader's readingMode to writing style. * Renamed the BookReadingMode to BookWritingStyle. And changed the migrations accordingly. * Fixed some minor bugs, regarding scrolling and vertical writing style when the book settings is open. * Fixed a minor bug where the graphics regarding the current page would require the mouse to be moved before it got updated when switching between writing styles. * Fixed some bugs regarding furigana getting a bit cropped same for images * Add vertical reading mode support and update API for reading mode preference * Removed dead code, added a fix for scroll end margins in chrome when in vertical mode(book reader). Added back some comments * Added Description()] annotation for the ReadingMode enum, like other enums, and added summary documentation * Added the ability to scroll in vertical writing style without holding down shift. Also renamed the book reader's readingMode to writing style. * Renamed the BookReadingMode to BookWritingStyle. And changed the migrations accordingly. * Fixed some minor bugs, regarding scrolling and vertical writing style when the book settings is open. * Fixed a minor bug where the graphics regarding the current page would require the mouse to be moved before it got updated when switching between writing styles. * Fixed some bugs regarding furigana getting a bit cropped same for images * Added reset support for writing style, after rebase. * Changes pagination for vertical scrolling such as the user will need to scroll to end before being able to paginate. Previously it felt unnatural and the user could accidentally paginate while scrolling on mobile. * Pagination would not stick to the left if the content was smaller than the reader in vertical writing style. * Fixed summary text * Added missing line, fixes build error * Addresses the comments given in code-review. * Moved columnGap outside the class, and changed it to a const --- API/Controllers/UsersController.cs | 1 + API/DTOs/UserPreferencesDto.cs | 6 + ...304202540_BookWritingStylePref.Designer.cs | 1854 +++++++++++++++++ .../20230304202540_BookWritingStylePref.cs | 26 + .../Migrations/DataContextModelSnapshot.cs | 3 + API/Entities/AppUserPreferences.cs | 4 + API/Entities/Enums/WritingStyle.cs | 20 + .../app/_models/preferences/preferences.ts | 3 + .../app/_models/preferences/writing-style.ts | 7 + UI/Web/src/app/_services/scroll.service.ts | 23 +- .../book-reader/book-reader.component.html | 20 +- .../book-reader/book-reader.component.scss | 55 +- .../book-reader/book-reader.component.ts | 230 +- .../reader-settings.component.html | 16 +- .../reader-settings.component.ts | 42 +- .../user-preferences.component.html | 46 +- .../user-preferences.component.ts | 16 +- 17 files changed, 2268 insertions(+), 104 deletions(-) create mode 100644 API/Data/Migrations/20230304202540_BookWritingStylePref.Designer.cs create mode 100644 API/Data/Migrations/20230304202540_BookWritingStylePref.cs create mode 100644 API/Entities/Enums/WritingStyle.cs create mode 100644 UI/Web/src/app/_models/preferences/writing-style.ts diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 413a47cb0..b1f8d98b9 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -99,6 +99,7 @@ public class UsersController : BaseApiController existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; + existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle; existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 41dbccf60..ae8a71f66 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -93,6 +93,12 @@ public class UserPreferencesDto [Required] public ReadingDirection BookReaderReadingDirection { get; set; } + /// + /// Book Reader Option: What writing style should be used, horizontal or vertical. + /// + [Required] + public WritingStyle BookReaderWritingStyle { get; set; } + /// /// UI Site Global Setting: The UI theme the user should use. /// diff --git a/API/Data/Migrations/20230304202540_BookWritingStylePref.Designer.cs b/API/Data/Migrations/20230304202540_BookWritingStylePref.Designer.cs new file mode 100644 index 000000000..37cc255ae --- /dev/null +++ b/API/Data/Migrations/20230304202540_BookWritingStylePref.Designer.cs @@ -0,0 +1,1854 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230304202540_BookWritingStylePref")] + partial class BookWritingStylePref + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230304202540_BookWritingStylePref.cs b/API/Data/Migrations/20230304202540_BookWritingStylePref.cs new file mode 100644 index 000000000..fd6703060 --- /dev/null +++ b/API/Data/Migrations/20230304202540_BookWritingStylePref.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class BookWritingStylePref : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BookReaderWritingStyle", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookReaderWritingStyle", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 980463794..bffad6524 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -221,6 +221,9 @@ namespace API.Data.Migrations b.Property("BookReaderReadingDirection") .HasColumnType("INTEGER"); + b.Property("BookReaderWritingStyle") + .HasColumnType("INTEGER"); + b.Property("BookReaderTapToPaginate") .HasColumnType("INTEGER"); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 828c19f72..835ac2cda 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -76,6 +76,10 @@ public class AppUserPreferences /// public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Book Reader Option: Defines the writing styles vertical/horizontal + /// + public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal; /// /// UI Site Global Setting: The UI theme the user should use. /// diff --git a/API/Entities/Enums/WritingStyle.cs b/API/Entities/Enums/WritingStyle.cs new file mode 100644 index 000000000..b2e086599 --- /dev/null +++ b/API/Entities/Enums/WritingStyle.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +/// +/// Represents the writing styles for the book-reader +/// +public enum WritingStyle +{ + /// + /// Vertical writing style for the book-reader + /// + [Description ("Vertical")] + Vertical = 0, + /// + /// Horizontal writing style for the book-reader + /// + [Description ("Horizontal")] + Horizontal = 1 +} diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 17b30f672..b80f946f4 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -7,6 +7,7 @@ import { ReaderMode } from './reader-mode'; import { ReadingDirection } from './reading-direction'; import { ScalingOption } from './scaling-option'; import { SiteTheme } from './site-theme'; +import {WritingStyle} from "./writing-style"; export interface Preferences { // Manga Reader @@ -28,6 +29,7 @@ export interface Preferences { bookReaderFontFamily: string; bookReaderTapToPaginate: boolean; bookReaderReadingDirection: ReadingDirection; + bookReaderWritingStyle: WritingStyle; bookReaderThemeName: string; bookReaderLayoutMode: BookPageLayoutMode; bookReaderImmersiveMode: boolean; @@ -41,6 +43,7 @@ export interface Preferences { } export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; +export const bookWritingStyles = [{text: 'Horizontal', value: WritingStyle.Horizontal}, {text: 'Vertical', value: WritingStyle.Vertical}]; 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: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}]; diff --git a/UI/Web/src/app/_models/preferences/writing-style.ts b/UI/Web/src/app/_models/preferences/writing-style.ts new file mode 100644 index 000000000..e4c031fef --- /dev/null +++ b/UI/Web/src/app/_models/preferences/writing-style.ts @@ -0,0 +1,7 @@ +/* + * Mode the user is reading the book in. Not applicable with ReaderMode.Webtoon + */ +export enum WritingStyle{ + Vertical = 0, + Horizontal = 1 +} diff --git a/UI/Web/src/app/_services/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts index 7e786f7ed..3a0837962 100644 --- a/UI/Web/src/app/_services/scroll.service.ts +++ b/UI/Web/src/app/_services/scroll.service.ts @@ -24,22 +24,31 @@ export class ScrollService { } get scrollPosition() { - return (window.pageYOffset - || document.documentElement.scrollTop + return (window.pageYOffset + || document.documentElement.scrollTop || document.body.scrollTop || 0); } - scrollTo(top: number, el: Element | Window = window) { + /* + * When in the scroll vertical position the scroll in the horizontal position is needed + */ + get scrollPositionX() { + return (window.pageXOffset + || document.documentElement.scrollLeft + || document.body.scrollLeft || 0); + } + + scrollTo(top: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'smooth') { el.scroll({ top: top, - behavior: 'smooth' + behavior: behavior }); } - - scrollToX(left: number, el: Element | Window = window) { + + scrollToX(left: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'auto') { el.scroll({ left: left, - behavior: 'auto' + behavior: behavior }); } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index d894c5bd8..4a61ac405 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -1,4 +1,4 @@ -
+
Skip to main content @@ -52,6 +52,7 @@ (styleUpdate)="updateReaderStyles($event)" (clickToPaginateChanged)="showPaginationOverlay($event)" (fullscreen)="toggleFullscreen()" + (bookReaderWritingStyle)="updateWritingStyle($event)" (layoutModeUpdate)="updateLayoutMode($event)" (readingDirection)="updateReadingDirection($event)" (immersiveMode)="updateImmersiveMode($event)" @@ -72,26 +73,25 @@
-
- +
+
-
+
-
- -
+
-
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 3b2d1184b..0183f0b3b 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 @@ -87,6 +87,7 @@ $action-bar-height: 38px; .fixed-top { z-index: 1022; + direction: ltr; } .dark-mode .overlay { @@ -95,7 +96,7 @@ $action-bar-height: 38px; .action-bar { - background-color: var(--br-actionbar-bg-color); + background-color: var(--br-actionbar-bg-color); overflow: hidden; box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%); max-height: $action-bar-height; @@ -109,13 +110,13 @@ $action-bar-height: 38px; -webkit-box-orient: vertical; overflow: hidden; } - + @media(max-width: 875px) { .book-title { display: none; } - } - + } + .book-title { margin-top: 10px; text-align: center; @@ -142,6 +143,10 @@ $action-bar-height: 38px; &.column-layout-2 { height: calc(var(--vh) * 100); } + + &.writing-style-vertical { + direction: rtl; + } } .reading-section { @@ -151,6 +156,7 @@ $action-bar-height: 38px; padding-top: $action-bar-height; padding-bottom: $action-bar-height; position: relative; + direction: ltr; //background-color: green !important; @@ -167,12 +173,18 @@ $action-bar-height: 38px; //padding-top: 0px; //padding-bottom: 0px; } + + &.writing-style-vertical { + writing-mode: vertical-rl; + height: 100%; + } } .book-container { position: relative; height: 100%; + //background-color: purple !important; &.column-layout-1 { @@ -182,6 +194,12 @@ $action-bar-height: 38px; &.column-layout-2 { height: calc((var(--vh, 1vh) * 100) - $action-bar-height); } + + &.writing-style-vertical { + // Fixes an issue where chrome will cut of margins, doesn't seem to affect other browsers + overflow: auto; + } + } .book-content { @@ -192,10 +210,22 @@ $action-bar-height: 38px; &.column-layout-1 { height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2 + + &.writing-style-vertical { + height: auto; + padding: 0 10px 0 0; + margin: 20px 0; + } } &.column-layout-2 { height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2 + + &.writing-style-vertical { + height: auto; + padding: 0 10px 0 0; + margin: 20px 0; + } } // &.immersive { @@ -228,6 +258,8 @@ $action-bar-height: 38px; position: fixed; width: 100%; bottom: 0px; + left: 0px; + writing-mode: horizontal-tb; } @@ -242,7 +274,7 @@ $action-bar-height: 38px; overflow-wrap: break-word; } - + } .column-layout-2 { @@ -254,7 +286,7 @@ $action-bar-height: 38px; overflow-wrap: break-word; } - + } @@ -293,7 +325,7 @@ $action-bar-height: 38px; position: absolute; right: 0px; top: $action-bar-height; - width: 20%; + width: 20vw; z-index: 3; cursor: pointer; background: transparent; @@ -330,7 +362,7 @@ $action-bar-height: 38px; position: absolute; left: 0px; top: $action-bar-height; - width: 20%; + width: 20vw; background: transparent; border-color: transparent; border: none !important; @@ -342,6 +374,7 @@ $action-bar-height: 38px; &.immersive { top: 0px; } + } @@ -360,7 +393,7 @@ $action-bar-height: 38px; .btn { &.btn-secondary { - color: var(--br-actionbar-button-text-color); + color: var(--br-actionbar-button-text-color); border-color: transparent; background-color: unset; @@ -368,7 +401,7 @@ $action-bar-height: 38px; border-color: var(--br-actionbar-button-hover-border-color); } } - + &.btn-outline-secondary { border-color: transparent; background-color: unset; @@ -380,7 +413,7 @@ $action-bar-height: 38px; span { background-color: unset; - color: var(--br-actionbar-button-text-color); + color: var(--br-actionbar-button-text-color); } i { 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 ac2086c7f..4d5465c29 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 @@ -17,6 +17,7 @@ import { animate, state, style, transition, trigger } from '@angular/animations' import { Stack } from 'src/app/shared/data-structures/stack'; import { MemberService } from 'src/app/_services/member.service'; import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; +import {WritingStyle} from "../../../_models/preferences/writing-style"; import { MangaFormat } from 'src/app/_models/manga-format'; import { LibraryService } from 'src/app/_services/library.service'; import { LibraryType } from 'src/app/_models/library'; @@ -42,11 +43,12 @@ interface HistoryPoint { /** * XPath to scroll to */ - scrollPart: string; + scrollPart: string; } const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up // TODO: Do I need this or can I change it with new fixed top height +const COLUMN_GAP = 20; // px /** * Styles that should be applied on the top level book-content tag */ @@ -201,6 +203,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Used for showing/hiding bottom action bar. Calculates if there is enough scroll to show it. * Will hide if all content in book is absolute positioned */ + + horizontalScrollbarNeeded = false; scrollbarNeeded = false; readingDirection: ReadingDirection = ReadingDirection.LeftToRight; clickToPaginate = false; @@ -250,6 +254,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; + writingStyle: WritingStyle = WritingStyle.Horizontal; + private readonly onDestroy = new Subject(); @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef; @@ -346,26 +352,39 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } get ColumnWidth() { + const base = this.writingStyle === WritingStyle.Vertical ? this.windowHeight : this.windowWidth; switch (this.layoutMode) { case BookPageLayoutMode.Default: return 'unset'; case BookPageLayoutMode.Column1: - return (this.windowWidth /2) + 'px'; + return (base / 2) + 'px'; case BookPageLayoutMode.Column2: - return ((this.windowWidth / 4)) + 'px'; + return (base / 4) + 'px'; + default: + return 'unset'; } } get ColumnHeight() { - if (this.layoutMode !== BookPageLayoutMode.Default) { + if (this.layoutMode !== BookPageLayoutMode.Default || this.writingStyle === WritingStyle.Vertical) { // Take the height after page loads, subtract the top/bottom bar const height = this.windowHeight - (this.topOffset * 2); this.document.documentElement.style.setProperty('--book-reader-content-max-height', `${height}px`); return height + 'px'; } + return 'unset'; } + get VerticalBookContentWidth() { + if (this.layoutMode !== BookPageLayoutMode.Default && this.writingStyle !== WritingStyle.Horizontal ) { + const width = this.getVerticalPageWidth() + this.document.documentElement.style.setProperty('--book-reader-content-max-width', `${width}px`); + return width + 'px'; + } + return ''; + } + get ColumnLayout() { switch (this.layoutMode) { case BookPageLayoutMode.Default: @@ -377,6 +396,22 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + get WritingStyle() { + switch (this.writingStyle) { + case WritingStyle.Horizontal: + return ''; + case WritingStyle.Vertical: + return 'writing-style-vertical'; + } + } + + get PageWidthForPagination() { + if (this.layoutMode === BookPageLayoutMode.Default && this.writingStyle === WritingStyle.Vertical && this.horizontalScrollbarNeeded) { + return 'unset'; + } + return '100%' + } + get PageHeightForPagination() { if (this.layoutMode === BookPageLayoutMode.Default) { // if the book content is less than the height of the container, override and return height of container for pagination area @@ -531,7 +566,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); return; } - + this.bookTitle = info.bookTitle; this.cdRef.markForCheck(); @@ -599,7 +634,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { onResize(){ // Update the window Height this.updateWidthAndHeightCalcs(); - const resumeElement = this.getFirstVisibleElementXPath(); if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { this.scrollTo(resumeElement); // This works pretty well, but not perfect @@ -625,6 +659,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + onWheel(event: WheelEvent) { + // This allows the user to scroll the page horizontally without holding shift + if (this.layoutMode !== BookPageLayoutMode.Default || this.writingStyle !== WritingStyle.Vertical) { + return; + } + if (event.deltaY !== 0) { + event.preventDefault() + this.scrollService.scrollToX( event.deltaY + this.reader.nativeElement.scrollLeft, this.reader.nativeElement); + } +} + closeReader() { this.readerService.closeReader(this.readingListMode, this.readingListId); } @@ -674,7 +719,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.isLoading = false; this.cdRef.markForCheck(); return; - } + } if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId && !this.prevChapterPrefetched) { this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { @@ -790,7 +835,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { this.page = this.domSanitizer.bypassSecurityTrustHtml(content); // PERF: Potential optimization to prefetch next/prev page and store in localStorage this.cdRef.markForCheck(); - + setTimeout(() => { this.addLinkClickHandlers(); this.updateReaderStyles(this.pageStyles); @@ -817,19 +862,33 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ updateImagesWithHeight() { const images = this.readingSectionElemRef?.nativeElement.querySelectorAll('img') || []; + let maxHeight: number | undefined; - if (this.layoutMode !== BookPageLayoutMode.Default) { - const height = (parseInt(this.ColumnHeight.replace('px', ''), 10) - (this.topOffset * 2)) + 'px'; - Array.from(images).forEach(img => { - this.renderer.setStyle(img, 'max-height', height); - }); + if (this.layoutMode !== BookPageLayoutMode.Default && this.writingStyle !== WritingStyle.Vertical) { + maxHeight = (parseInt(this.ColumnHeight.replace('px', ''), 10) - (this.topOffset * 2)); + } else if (this.layoutMode !== BookPageLayoutMode.Column2 && this.writingStyle === WritingStyle.Vertical) { + maxHeight = this.getPageHeight() - COLUMN_GAP; + } else if (this.layoutMode === BookPageLayoutMode.Column2 && this.writingStyle === WritingStyle.Vertical) { + maxHeight = this.getPageHeight() / 2 - COLUMN_GAP; } else { - Array.from(images).forEach(img => { - this.renderer.removeStyle(img, 'max-height'); - }); + maxHeight = undefined; } + Array.from(images).forEach(img => { + if (maxHeight === undefined) { + this.renderer.removeStyle(img, 'max-height'); + } else if (this.writingStyle === WritingStyle.Horizontal) { + this.renderer.setStyle(img, 'max-height', `${maxHeight}px`); + } else { + const aspectRatio = img.width / img.height; + const pageWidth = this.getVerticalPageWidth() + const maxImgHeight = Math.min(maxHeight, pageWidth / aspectRatio); + this.renderer.setStyle(img, 'max-height', `${maxImgHeight}px`); + } + }); + } + setupPage(part?: string | undefined, scrollTop?: number | undefined) { this.isLoading = false; this.cdRef.markForCheck(); @@ -846,11 +905,20 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.scrollTo(part); } else if (scrollTop !== undefined && scrollTop !== 0) { this.scrollService.scrollTo(scrollTop, this.reader.nativeElement); + } else if ((this.writingStyle === WritingStyle.Vertical) && (this.layoutMode === BookPageLayoutMode.Default)) { + setTimeout(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement)); } else { if (this.layoutMode === BookPageLayoutMode.Default) { this.scrollService.scrollTo(0, this.reader.nativeElement); - } else { + } else if (this.writingStyle === WritingStyle.Vertical) { + if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { + setTimeout(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement)); + } else { + setTimeout(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement)); + } + } + else { this.reader.nativeElement.children // We need to check if we are paging back, because we need to adjust the scroll if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { @@ -858,7 +926,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { setTimeout(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement)); } - } + } } // we need to click the document before arrow keys will scroll down. @@ -909,7 +977,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Given a direction, calls the next or prev page method - * @param direction Direction to move + * @param direction Direction to move */ movePage(direction: PAGING_DIRECTION) { if (direction === PAGING_DIRECTION.BACKWARDS) { @@ -931,7 +999,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage > 1) { // -2 apparently goes back 1 virtual page... - this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement); + if (this.writingStyle === WritingStyle.Vertical) { + this.scrollService.scrollTo((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement, "auto"); + } else { + this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement); + } this.handleScrollEvent(); return; } @@ -956,14 +1028,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.pagingDirection = PAGING_DIRECTION.FORWARD; - // We need to handle virtual paging before we increment the actual page if (this.layoutMode !== BookPageLayoutMode.Default) { const [currentVirtualPage, totalVirtualPages, pageWidth] = this.getVirtualPage(); if (currentVirtualPage < totalVirtualPages) { // +0 apparently goes forward 1 virtual page... - this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement); + if (this.writingStyle === WritingStyle.Vertical) { + this.scrollService.scrollTo( (currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement, 'auto'); + } else { + this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement); + } this.handleScrollEvent(); return; } @@ -985,47 +1060,75 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } /** - * + * * @returns Total Page width (excluding margin) */ getPageWidth() { if (this.readingSectionElemRef == null) return 0; - const margin = (this.readingSectionElemRef.nativeElement.clientWidth * (parseInt(this.pageStyles['margin-left'], 10) / 100)) * 2; - const columnGap = 20; - return this.readingSectionElemRef.nativeElement.clientWidth - margin + columnGap; + + return this.readingSectionElemRef.nativeElement.clientWidth - margin + COLUMN_GAP; + } + + getPageHeight() { + if (this.readingSectionElemRef == null) return 0; + const height = (parseInt(this.ColumnHeight.replace('px', ''), 10)); + + return height - COLUMN_GAP; + } + + getVerticalPageWidth() { + const margin = (window.innerWidth * (parseInt(this.pageStyles['margin-left'], 10) / 100)) * 2; + const windowWidth = window.innerWidth || document.documentElement.clientWidth; + return windowWidth - margin; } /** * currentVirtualPage starts at 1 - * @returns + * @returns */ getVirtualPage() { - if (this.bookContentElemRef === undefined || this.readingSectionElemRef === undefined) return [1, 1, 0]; + if (!this.bookContentElemRef || !this.readingSectionElemRef) return [1, 1, 0]; - const scrollOffset = this.bookContentElemRef.nativeElement.scrollLeft; - const totalScroll = this.bookContentElemRef.nativeElement.scrollWidth; - const pageWidth = this.getPageWidth(); - const delta = totalScroll - scrollOffset; - - const totalVirtualPages = Math.max(1, Math.round((totalScroll) / pageWidth)); + const [scrollOffset, totalScroll] = this.getScrollOffsetAndTotalScroll(); + const pageSize = this.getPageSize(); + const totalVirtualPages = Math.max(1, Math.round(totalScroll / pageSize)); + const delta = scrollOffset - totalScroll; let currentVirtualPage = 1; - // If first virtual page, i.e. totalScroll and delta are the same value - if (totalScroll - delta === 0) { + //If first virtual page, i.e. totalScroll and delta are the same value + if (totalScroll === delta) { currentVirtualPage = 1; - // If second virtual page - } else if (totalScroll - delta === pageWidth) { + // If second virtual page + } else if (totalScroll - delta === pageSize) { currentVirtualPage = 2; - - // Otherwise do math to get correct page. i.e. scrollOffset + pageWidth (this accounts for first page offset) + // Otherwise do math to get correct page. i.e. scroll + pageHeight/pageWidth (this accounts for first page offset) } else { - currentVirtualPage = Math.min(Math.max(1, Math.round((scrollOffset + pageWidth) / pageWidth)), totalVirtualPages); - } + currentVirtualPage = Math.min(Math.max(1, Math.round((scrollOffset + pageSize) / pageSize)), totalVirtualPages); + } + + return [currentVirtualPage, totalVirtualPages, pageSize]; - return [currentVirtualPage, totalVirtualPages, pageWidth]; } + private getScrollOffsetAndTotalScroll() { + const { nativeElement: bookContent } = this.bookContentElemRef; + const scrollOffset = this.writingStyle === WritingStyle.Vertical + ? bookContent.scrollTop + : bookContent.scrollLeft; + const totalScroll = this.writingStyle === WritingStyle.Vertical + ? bookContent.scrollHeight + : bookContent.scrollWidth; + return [scrollOffset, totalScroll]; + } + + private getPageSize() { + return this.writingStyle === WritingStyle.Vertical + ? this.getPageHeight() + : this.getPageWidth(); + } + + getFirstVisibleElementXPath() { let resumeElement: string | null = null; if (this.bookContentElemRef === null) return null; @@ -1059,7 +1162,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Before we apply styles, let's get an element on the screen so we can scroll to it after any shifts const resumeElement: string | null | undefined = this.getFirstVisibleElementXPath(); - + // Needs to update the image size when reading mode is vertically + if (this.writingStyle === WritingStyle.Vertical) { + this.updateImagesWithHeight(); + } // Line Height must be placed on each element in the page // Apply page level overrides @@ -1097,7 +1203,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Applies styles and classes that control theme - * @param theme + * @param theme */ updateColorTheme(theme: BookTheme) { // Remove all themes @@ -1121,6 +1227,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Recalculate if bottom action bar is needed this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; + this.horizontalScrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientWidth > this.reader?.nativeElement?.clientWidth; this.cdRef.markForCheck(); } @@ -1148,7 +1255,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (element === null) return; - if (this.layoutMode === BookPageLayoutMode.Default) { + if(this.layoutMode === BookPageLayoutMode.Default && this.writingStyle === WritingStyle.Vertical ) { + const windowWidth = window.innerWidth || document.documentElement.clientWidth; + const scrollLeft = element.getBoundingClientRect().left + window.pageXOffset - (windowWidth - element.getBoundingClientRect().width); + setTimeout(() => this.scrollService.scrollToX(scrollLeft, this.reader.nativeElement, 'smooth'), 10); + } + else if ((this.layoutMode === BookPageLayoutMode.Default) && (this.writingStyle === WritingStyle.Horizontal)) { const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); @@ -1210,7 +1322,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.isFullscreen = true; this.cdRef.markForCheck(); // HACK: This is a bug with how browsers change the background color for fullscreen mode - this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); + this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); if (!this.darkMode) { this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); } @@ -1218,11 +1330,29 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + updateWritingStyle(writingStyle: WritingStyle) { + this.writingStyle = writingStyle; + if (this.layoutMode !== BookPageLayoutMode.Default) { + const lastSelector = this.lastSeenScrollPartPath; + setTimeout(() => { + this.scrollTo(lastSelector); + this.updateLayoutMode(this.layoutMode); + }); + } else if (this.bookContentElemRef !== undefined) { + const resumeElement = this.getFirstVisibleElementXPath(); + if (resumeElement) { + setTimeout(() => { + this.scrollTo(resumeElement); + }); + } + } + this.cdRef.markForCheck(); + } + updateLayoutMode(mode: BookPageLayoutMode) { const layoutModeChanged = mode !== this.layoutMode; this.layoutMode = mode; this.cdRef.markForCheck(); - // Remove any max-heights from column layout this.updateImagesWithHeight(); @@ -1233,10 +1363,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } setTimeout(() => { this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; + this.horizontalScrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientWidth > this.reader?.nativeElement?.clientWidth; this.cdRef.markForCheck(); }); - // When I switch layout, I might need to resume the progress point. + // When I switch layout, I might need to resume the progress point. if (mode === BookPageLayoutMode.Default && layoutModeChanged) { const lastSelector = this.lastSeenScrollPartPath; setTimeout(() => this.scrollTo(lastSelector)); @@ -1263,7 +1394,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setTimeout(() => { if (renderer === undefined || elem === undefined) return; if (this.immersiveMode) { - renderer.setStyle(elem, 'height', 'calc(var(--vh, 1vh) * 100)', RendererStyleFlags2.Important); } else { renderer.setStyle(elem, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); } diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html index f28ce296e..30c5e8520 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html @@ -70,6 +70,16 @@  {{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}
+
+ + Changes the direction of the text. Horizontal is left to right, vertical is top to bottom. + + + +
Click the edges of the screen to paginate @@ -116,10 +126,10 @@
- + - +
@@ -150,4 +160,4 @@ - \ No newline at end of file + diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts index 0860c0b64..694e41857 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts @@ -5,6 +5,7 @@ import { Subject, take, takeUntil } from 'rxjs'; import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode'; import { BookTheme } from 'src/app/_models/preferences/book-theme'; import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; +import { WritingStyle } from 'src/app/_models/preferences/writing-style'; import { ThemeProvider } from 'src/app/_models/preferences/site-theme'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; @@ -88,6 +89,10 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { * Outputs when reading direction is changed */ @Output() readingDirection: EventEmitter = new EventEmitter(); + /** + * Outputs when reading mode is changed + */ + @Output() bookReaderWritingStyle: EventEmitter = new EventEmitter(); /** * Outputs when immersive mode is changed */ @@ -106,6 +111,9 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { readingDirectionModel: ReadingDirection = ReadingDirection.LeftToRight; + writingStyleModel: WritingStyle = WritingStyle.Horizontal; + + activeTheme: BookTheme | undefined; isFullscreen: boolean = false; @@ -129,6 +137,10 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { return ReadingDirection; } + get WritingStyle() { + return WritingStyle; + } + constructor(private bookService: BookService, private accountService: AccountService, @@ -160,7 +172,12 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { if (this.user.preferences.bookReaderReadingDirection === undefined) { this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight; } + if (this.user.preferences.bookReaderWritingStyle === undefined) { + this.user.preferences.bookReaderWritingStyle = WritingStyle.Horizontal; + } this.readingDirectionModel = this.user.preferences.bookReaderReadingDirection; + this.writingStyleModel = this.user.preferences.bookReaderWritingStyle; + this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); @@ -194,11 +211,13 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, [])); this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { - this.pageStyles['margin-left'] = value + '%'; - this.pageStyles['margin-right'] = value + '%'; + this.pageStyles['margin-left'] = value + 'vw'; + this.pageStyles['margin-right'] = value + 'vw'; this.styleUpdate.emit(this.pageStyles); }); + + this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((layoutMode: BookPageLayoutMode) => { this.layoutModeUpdate.emit(layoutMode); @@ -218,6 +237,7 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { // Emit first time so book reader gets the setting this.readingDirection.emit(this.readingDirectionModel); + this.bookReaderWritingStyle.emit(this.writingStyleModel); this.clickToPaginateChanged.emit(this.user.preferences.bookReaderTapToPaginate); this.layoutModeUpdate.emit(this.user.preferences.bookReaderLayoutMode); this.immersiveMode.emit(this.user.preferences.bookReaderImmersiveMode); @@ -239,7 +259,7 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { resetSettings() { if (this.user) { - this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + '%', this.user.preferences.bookReaderLineSpacing + '%'); + this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + 'vw', this.user.preferences.bookReaderLineSpacing + '%'); } else { this.setPageStyles(); } @@ -252,6 +272,7 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate); this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode); this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode); + this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.user.preferences.bookReaderWritingStyle); this.cdRef.detectChanges(); this.styleUpdate.emit(this.pageStyles); } @@ -265,9 +286,9 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { || this.document.body.clientWidth; - let defaultMargin = '15%'; + let defaultMargin = '15vw'; if (windowWidth <= mobileBreakpointMarginOverride) { - defaultMargin = '5%'; + defaultMargin = '5vw'; } this.pageStyles = { 'font-family': fontFamily || this.pageStyles['font-family'] || 'default', @@ -296,6 +317,17 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { this.readingDirection.emit(this.readingDirectionModel); } + toggleWritingStyle() { + if (this.writingStyleModel === WritingStyle.Horizontal) { + this.writingStyleModel = WritingStyle.Vertical + } else { + this.writingStyleModel = WritingStyle.Horizontal + } + + this.cdRef.markForCheck(); + this.bookReaderWritingStyle.emit(this.writingStyleModel); + } + toggleFullscreen() { this.isFullscreen = !this.isFullscreen; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index c92d9c08f..fe9a594d8 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -47,7 +47,7 @@
- + Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers) Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers)
@@ -59,7 +59,7 @@
- + Prompt when a download exceedes 100MB in size Prompt when a download exceedes 100MB in size
@@ -142,11 +142,12 @@
+ class="form-control" + id="settings-backgroundcolor-option" + (colorPickerChange)="handleBackgroundColorChange()" + [style.background]="user.preferences.backgroundColor" + [cpAlphaChannel]="'disabled'" + [(colorPicker)]="user.preferences.backgroundColor"/>
@@ -251,6 +252,15 @@
+
+ + Changes the direction of the text. Horizontal is left to right, vertical is top to bottom. + + +
+
  How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page @@ -259,26 +269,28 @@
+
+
-   - What color theme to apply to the book reader content and menuing - - -
+   + What color theme to apply to the book reader content and menuing + + +
- {{settingsForm.get('bookReaderFontSize')?.value + '%'}}
- +
@@ -286,7 +298,7 @@ How much spacing between the lines of the book How much spacing between the lines of the book
- {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}
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 04ac999a3..07e2cfd95 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,7 +3,17 @@ import { FormControl, FormGroup } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { take, takeUntil } from 'rxjs/operators'; import { Title } from '@angular/platform-browser'; -import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes, pageLayoutModes } from 'src/app/_models/preferences/preferences'; +import { + readingDirections, + scalingOptions, + pageSplitOptions, + readingModes, + Preferences, + bookLayoutModes, + layoutModes, + pageLayoutModes, + bookWritingStyles +} 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'; @@ -45,6 +55,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { bookLayoutModes = bookLayoutModes; bookColorThemes = bookColorThemes; pageLayoutModes = pageLayoutModes; + bookWritingStyles = bookWritingStyles; settingsForm: FormGroup = new FormGroup({}); user: User | undefined = undefined; @@ -134,6 +145,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { 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('bookReaderWritingStyle', new FormControl(this.user.preferences.bookReaderWritingStyle, [])) this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(!!this.user.preferences.bookReaderTapToPaginate, [])); this.settingsForm.addControl('bookReaderLayoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); this.settingsForm.addControl('bookReaderThemeName', new FormControl(this.user?.preferences.bookReaderThemeName || bookColorThemes[0].name, [])); @@ -179,6 +191,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.get('bookReaderMargin')?.setValue(this.user.preferences.bookReaderMargin); this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate); this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.user.preferences.bookReaderReadingDirection); + this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.user.preferences.bookReaderWritingStyle); this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode); this.settingsForm.get('bookReaderThemeName')?.setValue(this.user.preferences.bookReaderThemeName); this.settingsForm.get('theme')?.setValue(this.user.preferences.theme); @@ -211,6 +224,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { bookReaderMargin: modelSettings.bookReaderMargin, bookReaderTapToPaginate: modelSettings.bookReaderTapToPaginate, bookReaderReadingDirection: parseInt(modelSettings.bookReaderReadingDirection, 10), + bookReaderWritingStyle: parseInt(modelSettings.bookReaderWritingStyle, 10), bookReaderLayoutMode: parseInt(modelSettings.bookReaderLayoutMode, 10), bookReaderThemeName: modelSettings.bookReaderThemeName, theme: modelSettings.theme,