Epub Text Bleeding Finally Fixed! (#4086)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Gazy Mahomar <gmahomarf@users.noreply.github.com>
Co-authored-by: Stefans.A <104719225+privatestefans@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-10-11 09:18:54 -05:00 committed by GitHub
parent 75e844404c
commit f7dca3806f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 4616 additions and 186 deletions

View File

@ -35,7 +35,8 @@ public class ScannerHelper
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png");
private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" };
private static readonly string[] ComicInfoExtensions = [".cbz", ".cbr", ".zip", ".rar"];
private static readonly string[] EpubExtensions = [".epub"];
public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper)
{
@ -43,7 +44,7 @@ public class ScannerHelper
_testOutputHelper = testOutputHelper;
}
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo>? comicInfos = null)
{
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos);
@ -113,7 +114,7 @@ public class ScannerHelper
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo> comicInfos = null)
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo>? comicInfos = null)
{
// Read the map file
var mapContent = await File.ReadAllTextAsync(mapPath);
@ -130,7 +131,7 @@ public class ScannerHelper
Directory.CreateDirectory(testDirectory);
// Generate the files and folders
await Scaffold(testDirectory, filePaths, comicInfos);
await Scaffold(testDirectory, filePaths ?? [], comicInfos);
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
@ -138,7 +139,7 @@ public class ScannerHelper
}
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo> comicInfos = null)
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo>? comicInfos = null)
{
foreach (var relativePath in filePaths)
{
@ -157,6 +158,10 @@ public class ScannerHelper
{
CreateMinimalCbz(fullPath, info);
}
else if (EpubExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var epubInfo))
{
CreateMinimalEpub(fullPath, epubInfo);
}
else
{
// Create an empty file
@ -205,4 +210,165 @@ public class ScannerHelper
return stringWriter.ToString().Replace("""<?xml version="1.0" encoding="utf-16"?>""",
@"<?xml version='1.0' encoding='utf-8'?>");
}
private void CreateMinimalEpub(string filePath, ComicInfo? comicInfo = null)
{
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
{
// EPUB requires a mimetype file as the first entry (uncompressed)
var mimetypeEntry = archive.CreateEntry("mimetype", CompressionLevel.NoCompression);
using (var mimetypeStream = mimetypeEntry.Open())
using (var writer = new StreamWriter(mimetypeStream, Encoding.ASCII))
{
writer.Write("application/epub+zip");
}
// Create META-INF/container.xml
var containerEntry = archive.CreateEntry("META-INF/container.xml");
using (var containerStream = containerEntry.Open())
using (var writer = new StreamWriter(containerStream, Encoding.UTF8))
{
writer.Write("""
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
""");
}
// Create content.opf with metadata
var contentOpf = GenerateContentOpf(comicInfo);
var contentEntry = archive.CreateEntry("OEBPS/content.opf");
using (var contentStream = contentEntry.Open())
using (var writer = new StreamWriter(contentStream, Encoding.UTF8))
{
writer.Write(contentOpf);
}
// Add a minimal chapter XHTML file
var chapterEntry = archive.CreateEntry("OEBPS/chapter1.xhtml");
using (var chapterStream = chapterEntry.Open())
using (var writer = new StreamWriter(chapterStream, Encoding.UTF8))
{
writer.Write("""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Chapter 1</title>
</head>
<body>
<p>Test content.</p>
</body>
</html>
""");
}
// Add the cover image
archive.CreateEntryFromFile(_imagePath, "OEBPS/cover.png");
}
Console.WriteLine($"Created minimal EPUB archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
}
private static string GenerateContentOpf(ComicInfo? comicInfo)
{
var sb = new StringBuilder();
sb.AppendLine("""<?xml version="1.0" encoding="UTF-8"?>""");
sb.AppendLine("""<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">""");
// Metadata section
sb.AppendLine(" <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xmlns:calibre=\"http://calibre.kovidgoyal.net/2009/metadata\">");
if (comicInfo != null)
{
if (!string.IsNullOrEmpty(comicInfo.Title))
sb.AppendLine($" <dc:title>{EscapeXml(comicInfo.Title)}</dc:title>");
else
sb.AppendLine(" <dc:title>Untitled</dc:title>");
if (!string.IsNullOrEmpty(comicInfo.Series))
{
sb.AppendLine($" <meta property=\"belongs-to-collection\" id=\"collection\">{EscapeXml(comicInfo.Series)}</meta>");
sb.AppendLine(" <meta refines=\"#collection\" property=\"collection-type\">series</meta>");
}
if (!string.IsNullOrEmpty(comicInfo.Writer))
sb.AppendLine($" <dc:creator opf:role=\"aut\">{EscapeXml(comicInfo.Writer)}</dc:creator>");
if (!string.IsNullOrEmpty(comicInfo.Publisher))
sb.AppendLine($" <dc:publisher>{EscapeXml(comicInfo.Publisher)}</dc:publisher>");
if (!string.IsNullOrEmpty(comicInfo.Summary))
sb.AppendLine($" <dc:description>{EscapeXml(comicInfo.Summary)}</dc:description>");
if (!string.IsNullOrEmpty(comicInfo.LanguageISO))
sb.AppendLine($" <dc:language>{EscapeXml(comicInfo.LanguageISO)}</dc:language>");
else
sb.AppendLine(" <dc:language>en</dc:language>");
if (!string.IsNullOrEmpty(comicInfo.Isbn))
sb.AppendLine($" <dc:identifier id=\"book-id\" opf:scheme=\"ISBN\">{EscapeXml(comicInfo.Isbn)}</dc:identifier>");
else
sb.AppendLine($" <dc:identifier id=\"book-id\">urn:uuid:{Guid.NewGuid()}</dc:identifier>");
if (comicInfo.Year > 0)
{
var date = $"{comicInfo.Year:D4}";
if (comicInfo.Month > 0)
{
date += $"-{comicInfo.Month:D2}";
if (comicInfo.Day > 0)
date += $"-{comicInfo.Day:D2}";
}
sb.AppendLine($" <dc:date>{date}</dc:date>");
}
if (!string.IsNullOrEmpty(comicInfo.TitleSort))
sb.AppendLine($" <meta name=\"calibre:title_sort\" content=\"{EscapeXml(comicInfo.TitleSort)}\"/>");
if (!string.IsNullOrEmpty(comicInfo.SeriesSort))
sb.AppendLine($" <meta name=\"calibre:series_sort\" content=\"{EscapeXml(comicInfo.SeriesSort)}\"/>");
if (!string.IsNullOrEmpty(comicInfo.Number))
sb.AppendLine($" <meta name=\"calibre:series_index\" content=\"{EscapeXml(comicInfo.Number)}\"/>");
}
else
{
sb.AppendLine(" <dc:title>Untitled</dc:title>");
sb.AppendLine(" <dc:language>en</dc:language>");
sb.AppendLine($" <dc:identifier id=\"book-id\">urn:uuid:{Guid.NewGuid()}</dc:identifier>");
}
sb.AppendLine(" </metadata>");
// Manifest section
sb.AppendLine(" <manifest>");
sb.AppendLine(" <item id=\"chapter1\" href=\"chapter1.xhtml\" media-type=\"application/xhtml+xml\"/>");
sb.AppendLine(" <item id=\"cover\" href=\"cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>");
sb.AppendLine(" </manifest>");
// Spine section
sb.AppendLine(" <spine>");
sb.AppendLine(" <itemref idref=\"chapter1\"/>");
sb.AppendLine(" </spine>");
sb.AppendLine("</package>");
return sb.ToString();
}
private static string EscapeXml(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&apos;");
}
}

View File

@ -133,7 +133,6 @@ public class SettingsController : BaseApiController
/// Is the minimum information setup for Email to work
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("is-email-setup")]
public async Task<ActionResult<bool>> IsEmailSetup()
{

View File

@ -120,6 +120,7 @@ public class UsersController : BaseApiController
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
existingPreferences.DataSaver = preferencesDto.DataSaver;
var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id))
.Select(l => l.Id).ToList();

View File

@ -11,6 +11,7 @@ public sealed record ReadingListItemDto
public int ChapterId { get; init; }
public int SeriesId { get; init; }
public string? SeriesName { get; set; }
public string? SeriesSortName { get; set; }
public MangaFormat SeriesFormat { get; set; }
public int PagesRead { get; set; }
public int PagesTotal { get; set; }

View File

@ -37,6 +37,9 @@ public sealed record UserPreferencesDto
/// <inheritdoc cref="API.Entities.AppUserPreferences.ColorScapeEnabled"/>
[Required]
public bool ColorScapeEnabled { get; set; } = true;
/// <inheritdoc cref="API.Entities.AppUserPreferences.DataSaver"/>
[Required]
public bool DataSaver { get; set; } = false;
/// <inheritdoc cref="API.Entities.AppUserPreferences.AniListScrobblingEnabled"/>
public bool AniListScrobblingEnabled { get; set; }

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -571,6 +571,9 @@ namespace API.Data.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("DataSaver")
.HasColumnType("INTEGER");
b.Property<bool>("EmulateBook")
.HasColumnType("INTEGER");

View File

@ -311,7 +311,7 @@ public class ReadingListRepository : IReadingListRepository
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
.RestrictAgainstAgeRestriction(user.GetAgeRestriction());
query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.NormalizedTitle);
query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.Title);
var finalQuery = query.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
@ -383,11 +383,13 @@ public class ReadingListRepository : IReadingListRepository
{
ReadingListItem = rli,
Chapter = chapter,
FileSize = _context.MangaFile.Where(f => f.ChapterId == chapter.Id).Sum(f => (long?)f.Bytes) ?? 0
})
.Join(_context.Volume, x => x.ReadingListItem.VolumeId, volume => volume.Id, (x, volume) => new
{
x.ReadingListItem,
x.Chapter,
x.FileSize,
Volume = volume
})
.Join(_context.Series, x => x.ReadingListItem.SeriesId, series => series.Id, (x, series) => new
@ -395,6 +397,7 @@ public class ReadingListRepository : IReadingListRepository
x.ReadingListItem,
x.Chapter,
x.Volume,
x.FileSize,
Series = series
})
.Where(x => userLibraries.Contains(x.Series.LibraryId))
@ -407,6 +410,7 @@ public class ReadingListRepository : IReadingListRepository
x.Chapter,
x.Volume,
x.Series,
x.FileSize,
ProgressGroup = progressGroup
})
.SelectMany(
@ -417,6 +421,7 @@ public class ReadingListRepository : IReadingListRepository
x.Chapter,
x.Volume,
x.Series,
x.FileSize,
Progress = progress,
PagesRead = progress != null ? progress.PagesRead : 0,
HasProgress = progress != null,
@ -447,6 +452,7 @@ public class ReadingListRepository : IReadingListRepository
Order = item.ReadingListItem.Order,
SeriesId = item.ReadingListItem.SeriesId,
SeriesName = item.Series.Name,
SeriesSortName = item.Series.SortName,
SeriesFormat = item.Series.Format,
PagesTotal = item.Chapter.Pages,
PagesRead = item.PagesRead,
@ -459,7 +465,7 @@ public class ReadingListRepository : IReadingListRepository
LibraryType = library.Type,
ChapterTitleName = item.Chapter.TitleName,
LibraryName = library.Name,
FileSize = item.Chapter.Files.Sum(f => f.Bytes), // TODO: See if we can put FileSize on the chapter in future
FileSize = item.FileSize,
Summary = item.Chapter.Summary,
IsSpecial = item.Chapter.IsSpecial,
LastReadingProgressUtc = item.Progress?.LastModifiedUtc
@ -513,6 +519,7 @@ public class ReadingListRepository : IReadingListRepository
(data, s) => new
{
SeriesName = s.Name,
SortName = s.SortName,
SeriesFormat = s.Format,
s.LibraryId,
data.ReadingListItem,
@ -541,6 +548,7 @@ public class ReadingListRepository : IReadingListRepository
Order = x.Data.ReadingListItem.Order,
SeriesId = x.Data.ReadingListItem.SeriesId,
SeriesName = x.Data.SeriesName,
SeriesSortName = x.Data.SortName,
SeriesFormat = x.Data.SeriesFormat,
PagesTotal = x.Data.TotalPages,
ChapterNumber = x.Data.ChapterNumber,

View File

@ -171,6 +171,13 @@ public class AppUserPreferences
/// UI Site Global Setting: Should Kavita render ColorScape gradients
/// </summary>
public bool ColorScapeEnabled { get; set; } = true;
/// <summary>
/// Enable data saver mode across Kavita, limiting information that is pre-fetched
/// </summary>
/// <remarks>Currenty only integrated into the PDF reader</remarks>
public bool DataSaver { get; set; } = false;
#endregion
#region KavitaPlus

View File

@ -10,41 +10,40 @@ using Microsoft.Extensions.Logging;
namespace API.Helpers;
#nullable enable
/**
* Contributed by https://github.com/microtherion
*
* All references to the "PDF Spec" (section numbers, etc.) refer to the
* PDF 1.7 Specification a.k.a. PDF32000-1:2008
* https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
*/
/**
* Reference for PDF Metadata Format
%PDF-1.4 Header
// Contributed by https://github.com/microtherion
//
// All references to the "PDF Spec" (section numbers, etc.) refer to the
// PDF 1.7 Specification a.k.a. PDF32000-1:2008
// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
Object 1 0 obj Objects containing content
<< /Type /Catalog ... >>
endobj
Object 2 0 obj
<< /Type /Info ... >>
endobj
...more objects...
xref Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n Object 1 is at byte offset 15
0000000109 00000 n Object 2 is at byte offset 109
...
trailer Trailer dictionary
<< /Size 6 /Root 1 0 R /Info 2 0 R >>
startxref
1234 Byte offset where xref starts
%%EOF
*/
// Reference for PDF Metadata Format
// <![CDATA[
// %PDF-1.4 ← Header
//
// Object 1 0 obj ← Objects containing content
// << /Type /Catalog ... >>
// endobj
//
// Object 2 0 obj
// << /Type /Info ... >>
// endobj
//
// ...more objects...
//
// xref ← Cross-reference table
// 0 6
// 0000000000 65535 f
// 0000000015 00000 n ← Object 1 is at byte offset 15
// 0000000109 00000 n ← Object 2 is at byte offset 109
// ...
//
// trailer ← Trailer dictionary
// << /Size 6 /Root 1 0 R /Info 2 0 R >>
// startxref
// 1234 ← Byte offset where xref starts
// %%EOF
// ]]>
/// <summary>
/// Parse PDF file and try to extract as much metadata as possible.
@ -1591,6 +1590,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor
case PdfLexer.TokenType.Name:
case PdfLexer.TokenType.String:
case PdfLexer.TokenType.ObjectRef:
case PdfLexer.TokenType.Keyword:
break;
case PdfLexer.TokenType.ArrayStart:
{
@ -1602,8 +1602,17 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor
SkipDictionary();
break;
}
case PdfLexer.TokenType.StreamStart:
{
// If we encounter a stream, we need to skip it properly
// This is tricky because we need the Length from the dictionary
// For now, throw a more informative exception
throw new PdfMetadataExtractorException(
"Encountered stream object in unexpected context - PDF may have inline streams in dictionary");
}
default:
throw new PdfMetadataExtractorException("Unexpected token in SkipValue");
throw new PdfMetadataExtractorException(
$"Unexpected token type in SkipValue: {token.Type} with value: {token.Value}");
}
}

View File

@ -244,7 +244,7 @@ public class ReadingListService : IReadingListService
// Collect all Ids to remove
var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id).ToList();
if (!itemIdsToRemove.Any()) return true;
if (itemIdsToRemove.Count == 0) return true;
try
{
var listItems =
@ -360,8 +360,7 @@ public class ReadingListService : IReadingListService
private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable<int> seriesIds)
{
var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
if (ageRating == null) readingList.AgeRating = AgeRating.Unknown;
else readingList.AgeRating = (AgeRating) ageRating;
readingList.AgeRating = ageRating;
}
/// <summary>

View File

@ -1,4 +1,5 @@
using API.Data.Metadata;
using System.IO;
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
@ -7,8 +8,26 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
{
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null)
{
var info = bookService.ParseInfo(filePath);
if (info == null) return null;
ParserInfo info;
if (enableMetadata)
{
info = bookService.ParseInfo(filePath);
if (info == null) return null;
}
else
{
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
info = new ParserInfo
{
Filename = Path.GetFileName(filePath),
Format = MangaFormat.Epub,
Title = Parser.RemoveExtensionIfSupported(fileName)!,
FullFilePath = Parser.NormalizePath(filePath),
Series = Parser.ParseSeries(fileName, type),
Chapters = Parser.ParseChapter(fileName, type),
Volumes = Parser.ParseVolume(fileName, type),
};
}
info.ComicInfo = comicInfo;

View File

@ -27,9 +27,16 @@ Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e te
## Connecting to your dev server via your phone or any other compatible client on local network
Update `IP` constant in `src/environments/environment.ts` to your dev machine's ip instead of `localhost`.
Run `npm run start-proxy`
Run `npm run start`
## Testing OIDC
There's two options,
1) Run the proxy and correct the port after redirect (on login).
2) Run `build-backend` or `build-backend-prod`, and use `localhost:5000` to test. This requires you to rebuild after each change
Do **NOT** commit appsettings.development.json while testing OIDC. It'll contain your secret key
## Notes:
- injected services should be at the top of the file

View File

@ -13,8 +13,11 @@
}
.tag-card:hover {
background-color: #3a3a3a;
background-color: var(--card-hover-bg-color);
//transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this
& .tag-name, & .tag-meta {
color: var(--card-hover-text-color)
}
}
.tag-name {

View File

@ -15,6 +15,7 @@ export interface Preferences {
locale: string;
bookReaderHighlightSlots: HighlightSlot[];
colorScapeEnabled: boolean;
dataSaver: boolean;
// Kavita+
aniListScrobblingEnabled: boolean;

View File

@ -9,6 +9,7 @@ export interface ReadingListItem {
pagesRead: number;
pagesTotal: number;
seriesName: string;
seriesSortName: string;
seriesFormat: MangaFormat;
seriesId: number;
chapterId: number;

View File

@ -38,7 +38,7 @@ export class FontService {
getFontFace(font: EpubFont): FontFace {
if (font.provider === FontProvider.System) {
return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`);
return new FontFace(font.name, `url('assets/fonts/${font.name}/${font.fileName}')`);
}
return new FontFace(font.name, `url(${this.baseUrl}font?fontId=${font.id}&apiKey=${this.encodedKey})`);

View File

@ -28,12 +28,12 @@
color: var(--dropdown-item-text-color);
background-color: var(--dropdown-item-bg-color);
&:hover {
color: var(--dropdown-item-text-color);
color: var(--dropdown-item-hover-text-color);
background-color: var(--dropdown-item-hover-bg-color);
cursor: pointer;
}
&:focus-visible {
color: var(--dropdown-item-text-color);
color: var(--dropdown-item-hover-text-color);
background-color: var(--dropdown-item-hover-bg-color);
}
}

View File

@ -58,6 +58,10 @@
cursor: pointer;
}
.card-title {
color: var(--card-overlay-text-color);
}
.overlay-information--centered {
position: absolute;
border-radius: 15px;

View File

@ -94,6 +94,7 @@
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-setting-multi-check-box
id="libraries"
[title]="t('libraries-label')"
[options]="libraryOptions()"
formControlName="libraries"
@ -102,6 +103,7 @@
<div class="col-md-6">
<app-setting-multi-check-box
id="roles"
[title]="t('roles-label')"
[options]="roleOptions"
formControlName="roles"

View File

@ -31,6 +31,7 @@
<div class="row g-0">
<div class="col-md-6 pe-4">
<app-setting-multi-check-box
id="libraries"
[title]="t('libraries-label')"
[options]="libraryOptions()"
formControlName="libraries"
@ -39,6 +40,7 @@
<div class="col-md-6">
<app-setting-multi-check-box
id="roles"
[title]="t('roles-label')"
[options]="roleOptions"
formControlName="roles"

View File

@ -205,19 +205,21 @@
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-setting-multi-check-box
[title]="t('default-roles-label')"
[options]="roleOptions"
id="libraries"
[title]="t('default-libraries-label')"
[options]="libraryOptions()"
[loading]="loading()"
formControlName="defaultRoles"
formControlName="defaultLibraries"
/>
</div>
<div class="col-md-6">
<app-setting-multi-check-box
[title]="t('default-libraries-label')"
[options]="libraryOptions()"
id="roles"
[title]="t('default-roles-label')"
[options]="roleOptions"
[loading]="loading()"
formControlName="defaultLibraries"
formControlName="defaultRoles"
/>
</div>
</div>

View File

@ -19,26 +19,26 @@
}
</div>
@if (clickToPaginate() && !hidePagination()) {
<div class="tap-to-paginate">
<div class="left {{clickOverlayClass('left')}} no-observe"
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
[ngClass]="{'immersive' : immersiveMode()}"
tabindex="-1"></div>
<div class="{{scrollbarNeeded() ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
[ngClass]="{'immersive' : immersiveMode()}"
tabindex="-1"></div>
</div>
}
<div #readingSection class="reading-section {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
[ngStyle]="{'width': pageWidthForPagination()}"
[ngClass]="{'immersive' : immersiveMode() || !actionBarVisible()}" [@isLoading]="isLoading()" (click)="handleReaderClick($event)">
@if (clickToPaginate() && !hidePagination()) {
<div class="left {{clickOverlayClass('left')}} no-observe"
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
[ngClass]="{'immersive' : immersiveMode()}"
tabindex="-1"
[ngStyle]="{height: PageHeightForPagination}"></div>
<div class="{{scrollbarNeeded() ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
[ngClass]="{'immersive' : immersiveMode()}"
tabindex="-1"
[ngStyle]="{height: PageHeightForPagination}"></div>
}
<div #bookContainer class="book-container {{writingStyle() | writingStyleClass}}"
[ngClass]="{'immersive' : immersiveMode()}"
(mousedown)="mouseDown($event)" >
[ngClass]="{'immersive' : immersiveMode()}"
(mousedown)="mouseDown($event)" >
<div #readingHtml class="book-content {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
[ngStyle]="{'max-height': columnHeight(), 'max-width': verticalBookContentWidth(), 'width': verticalBookContentWidth(), 'column-width': columnWidth()}"
@ -63,7 +63,7 @@
</button>
</div>
<div class="text-center d-none d-md-block center-group">
<div class="text-center center-group">
@if (isLoading()) {
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
<span class="visually-hidden">{{ t('loading-book') }}</span>

View File

@ -84,6 +84,7 @@ $action-bar-height: 38px;
}
.center-group {
display: block;
justify-self: center;
}
@ -98,6 +99,10 @@ $action-bar-height: 38px;
grid-template-columns: auto 1fr;
}
.center-group {
display: none;
}
.right-group {
justify-self: end;
}
@ -234,6 +239,7 @@ $action-bar-height: 38px;
bottom: 0;
left: 0;
writing-mode: horizontal-tb;
z-index: 4;
}
@ -306,14 +312,21 @@ $pagination-opacity: 0;
//$pagination-opacity: 0.7;
.tap-to-paginate {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 0px;
z-index: 3;
}
.right {
position: absolute;
right: 0px;
top: $action-bar-height;
width: 20vw;
z-index: 3;
height: calc(100vh - $action-bar-height*2);
background: $pagination-color;
border-color: transparent;
border: none !important;
@ -323,6 +336,7 @@ $pagination-opacity: 0;
&.immersive {
top: 0;
height: 100vh;
}
&.no-pointer-events {
@ -335,8 +349,8 @@ $pagination-opacity: 0;
position: absolute;
right: 17px;
top: $action-bar-height;
width: 18%;
z-index: 3;
width: 18vw;
height: calc(100vh - $action-bar-height*2);
background: $pagination-color;
opacity: $pagination-opacity;
border-color: transparent;
@ -346,6 +360,7 @@ $pagination-opacity: 0;
&.immersive {
top: 0;
height: 100vh;
}
}
@ -354,17 +369,17 @@ $pagination-opacity: 0;
left: 0px;
top: $action-bar-height;
width: 20vw;
height: calc(100vh - $action-bar-height*2);
background: $pagination-color;
opacity: $pagination-opacity;
border-color: transparent;
border: none !important;
z-index: 3;
outline: none;
height: 100vw;
cursor: pointer;
&.immersive {
top: 0px;
height: 100vh;
}
}
@ -410,7 +425,7 @@ $pagination-opacity: 0;
i {
background-color: unset;
color: var(--br-actionbar-button-text-color);
color: var(--br-actionbar-button-text-color) !important;
}
&:active {

View File

@ -359,6 +359,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
debugMode = model<boolean>(!environment.production && true);
/**
* Will be set to true if this.scroll(...) is called but the actual scroll is still delayed
* This can also be used to debug glitches or race conditions related to page scrolling
* For instance, when we invoke a scroll action, but another scroll is scheduled to be triggered afterward
*/
hasDelayedScroll: boolean = false;
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
@ -506,23 +512,26 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
get PageHeightForPagination() {
pageHeightForPagination = computed(() => {
const layoutMode = this.layoutMode();
const immersiveMode = this.immersiveMode();
const widthHeight = this.windowHeight();
if (layoutMode=== BookPageLayoutMode.Default) {
if (layoutMode === BookPageLayoutMode.Default) {
// Ensure Angular updates this pageHeightForPagination when these signal have an update
if (this.isLoading()) return;
this.windowHeight();
this.writingStyle();
// if the book content is less than the height of the container, override and return height of container for pagination area
if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) {
return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px';
}
return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px';
return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px';
}
if (immersiveMode) return widthHeight + 'px';
return (widthHeight) - (this.topOffset * 2) + 'px';
}
return '100%';
});
constructor() {
this.navService.hideNavBar();
@ -534,9 +543,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const layoutMode = this.layoutMode();
const writingStyle = this.writingStyle();
const windowWidth = this.windowWidth();
const marginLeft = this.pageStyles()['margin-left'];
const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2);
// const windowWidth = this.windowWidth();
// const marginLeft = this.pageStyles()['margin-left'];
// const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2);
const base = writingStyle === WritingStyle.Vertical ? this.pageHeight() : this.pageWidth();
@ -950,27 +959,33 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.updateWidthAndHeightCalcs();
this.updateImageSizes();
// Refresh page styles to handle margin changes on window resize
this.applyPageStyles(this.pageStyles());
// Attempt to restore the reading position
this.snapScrollOnResize();
afterFrame(() => {
this.injectImageBookmarkIndicators(true);
});
}
/**
* Only applies to non BookPageLayoutMode. Default and WritingStyle Horizontal
* Only applies to non BookPageLayoutMode.Default and WritingStyle Horizontal
* @private
*/
private snapScrollOnResize() {
const layoutMode = this.layoutMode();
if (layoutMode === BookPageLayoutMode.Default) return;
const resumeElement = this.lastSeenScrollPartPath || (this.getFirstVisibleElementXPath() ?? '');
if (resumeElement) {
const resumeElement = this.getFirstVisibleElementXPath() ?? null;
if (resumeElement !== null) {
const element = this.getElementFromXPath(resumeElement);
//console.log('Attempting to snap to element: ', element);
if (this.debugMode()) {
const element = this.getElementFromXPath(resumeElement);
//console.log('Attempting to snap to element: ', element);
this.logSelectedElement('yellow');
}
this.scrollTo(resumeElement, 30); // This works pretty well, but not perfect
}
@ -1360,7 +1375,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.document.documentElement.style.setProperty('--book-reader-content-max-height', maxHeight);
this.document.documentElement.style.setProperty('--book-reader-content-max-width', maxWidth);
}
updateSingleImagePageStyles() {
@ -1401,7 +1415,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// Virtual Paging stuff
this.updateWidthAndHeightCalcs();
this.applyLayoutMode(this.layoutMode());
this.addEmptyPageIfRequired();
// this.addEmptyPageIfRequired(); // Already called in this.applyPageStyles()
// Find all the part ids and their top offset
this.setupPageAnchors();
@ -1415,7 +1429,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// we need to click the document before arrow keys will scroll down.
this.reader.nativeElement.focus();
this.scroll(() => this.handleScrollEvent()); // Will set lastSeenXPath and save progress
afterFrame(() => this.handleScrollEvent()); // Will set lastSeenXPath and save progress
this.isLoading.set(false);
this.cdRef.markForCheck();
@ -1425,8 +1439,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
private scroll(lambda: () => void) {
if (this.hasDelayedScroll) console.warn("Another scroll operation is still pending while this scroll function is being called again");
this.hasDelayedScroll = true;
// `afterFrame() + setTimeout()` can likely be replaced with `requestAnimationFrame()` instead
afterFrame(() => {
setTimeout(lambda, SCROLL_DELAY)
setTimeout(() => {
this.hasDelayedScroll = false;
lambda();
}, SCROLL_DELAY)
});
}
@ -1485,26 +1506,34 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
private addEmptyPageIfRequired(): void {
const bookContentElem = this.bookContentElemRef.nativeElement;
const oldEmptyGap = bookContentElem.querySelector('.kavita-empty-gap');
if (this.layoutMode() !== BookPageLayoutMode.Column2 || this.isSingleImagePage) {
oldEmptyGap?.remove(); // We don't need empty gap for this condition
return;
}
const pageSize = this.pageSize();
const [_, totalScroll] = this.getScrollOffsetAndTotalScroll();
let [_, totalScroll] = this.getScrollOffsetAndTotalScroll();
if (oldEmptyGap) totalScroll -= pageSize/2;
const lastPageSize = totalScroll % pageSize;
if (lastPageSize >= pageSize / 2 || lastPageSize === 0) {
// The last page needs more than one column, no pages will be duplicated
oldEmptyGap?.remove();
return;
}
// Need to adjust height with the column gap to ensure we don't have too much extra page
const columnHeight = this.pageHeight() - COLUMN_GAP;
const emptyPage = this.renderer.createElement('div');
const columnHeight = this.pageHeight() - (COLUMN_GAP * 2);
const emptyPage = oldEmptyGap ?? this.renderer.createElement('div');
emptyPage.classList.add('kavita-empty-gap');
this.renderer.setStyle(emptyPage, 'height', columnHeight + 'px');
this.renderer.setStyle(emptyPage, 'width', this.columnWidth());
this.renderer.appendChild(this.bookContentElemRef.nativeElement, emptyPage);
this.renderer.appendChild(bookContentElem, emptyPage);
}
goBack() {
@ -1548,7 +1577,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (currentVirtualPage > 1) {
// Calculate the target scroll position for the previous page
const targetScroll = (currentVirtualPage - 2) * pageSize - (this.layoutMode() === BookPageLayoutMode.Column2 ? 3 : 0)
const targetScroll = (currentVirtualPage - 2) * pageSize;
const isVertical = this.writingStyle() === WritingStyle.Vertical;
@ -1601,7 +1630,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (currentVirtualPage < totalVirtualPages) {
// Calculate the target scroll position for the next page
const targetScroll = (currentVirtualPage * pageSize) + (this.layoutMode() === BookPageLayoutMode.Column2 ? 1 : 0);
const targetScroll = (currentVirtualPage * pageSize);
const isVertical = this.writingStyle() === WritingStyle.Vertical;
// +0 apparently goes forward 1 virtual page...
@ -1649,9 +1678,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const columnGapModifier = this.columnGapModifier();
if (this.readingSectionElemRef == null) return 0;
// Give an additional pixels for buffer
return this.readingSectionElemRef.nativeElement.clientWidth - margin
+ (COLUMN_GAP * columnGapModifier);
return this.reader.nativeElement.offsetWidth - margin + (COLUMN_GAP * columnGapModifier);
});
columnGapModifier = computed(() => {
@ -1674,13 +1701,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
});
getVerticalPageWidth() {
getVerticalPageWidth = computed(() => {
if (!(this.pageStyles() || {}).hasOwnProperty('margin-left')) return 0; // TODO: Test this, added for safety during refactor
const margin = (window.innerWidth * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
const margin = (this.windowWidth() * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2;
const windowWidth = this.windowWidth() || document.documentElement.clientWidth;
return windowWidth - margin;
}
});
convertVwToPx(vwValue: number) {
const viewportWidth = Math.max(this.readingSectionElemRef?.nativeElement?.clientWidth ?? 0, window.innerWidth || 0);
@ -1741,21 +1768,40 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
getFirstVisibleElementXPath() {
let resumeElement: string | null = null;
if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null;
const bookContentElement = this.bookContentElemRef?.nativeElement;
if (!bookContentElement) return null;
//const container = this.getViewportBoundingRect();
const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span'))
const intersectingEntries = Array.from(bookContentElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span,figure'))
.filter(element => !element.classList.contains('no-observe'))
.filter(entry => {
//return this.isPartiallyContainedIn(container, entry);
return this.utilityService.isInViewport(entry, this.topOffset);
.filter(element => {
//return this.isPartiallyContainedIn(container, element);
return this.utilityService.isInViewport(element, this.topOffset)
/* Remove main container element
<div class="book-content"> <-- bookContentElement
<div class="body"> <--- we don't need this
<style></style>
...
</div>
</div>
*/
&& element.parentElement !== bookContentElement;
})
.filter((element, i, entries) => {
// Remove any children element contained in another element that exist on this entries
return !entries.some(item => element !== item && item.contains(element))
// Remove element that don't have any content
&& (element.textContent?.trim().length || element.querySelectorAll('img, svg').length !== 0 || /^(img|svg)$/im.test(element.tagName));
});
intersectingEntries.sort((a, b) => this.sortElementsForLayout(a, b));
if (intersectingEntries.length > 0) {
let path = this.readerService.getXPathTo(intersectingEntries[0]);
const element = this.findTopLevelElement(intersectingEntries[0], intersectingEntries[1], bookContentElement);
let path = this.readerService.getXPathTo(element);
if (path === '') return;
resumeElement = path;
@ -1763,6 +1809,63 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return resumeElement;
}
/**
* Finds the top level element that has the same parent.
* Illustrated with example below:
*
* <section>
* <p> <-- We want to get this element instead
* <span> ... target ... </span>
* </p>
* <p>
* <span> ... nextSibling ... </span>
* </p>
* <section>
*/
private findTopLevelElement(target: Element, nextSibling: Element, root: Element): Element | null {
// If no sibling provided, then lets transverse to parent element where the element display is not inline
if (nextSibling == null) {
let current: Element | null = target;
while (current && current !== root) {
const displayStyle = window.getComputedStyle(current).getPropertyValue('display');
if (!displayStyle.includes('inline')) return current;
current = current.parentElement;
}
return current;
}
// Immediately return if it's already sibling
if (target.parentElement === nextSibling.parentElement) return target;
const ancestors: Element[] = [];
let current: Element | null = null
// Collect all parent element from the next sibling
current = nextSibling.parentElement;
while (current && current !== root) {
ancestors.push(current);
current = current.parentElement;
}
// Traverse up from target to find the similar parent with nextSibling
current = target;
while (current && current !== root) {
let parent: Element | null = current.parentElement;
if (parent && ancestors.includes(parent)) {
return current;
}
current = parent;
}
console.warn("Unable to find similar parent element from the next sibling", target, nextSibling);
return target;
}
/**
* Sort elements based on layout mode for better scroll position tracking
*/
@ -1881,7 +1984,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.updateImageSizes(); // Re-call this as we will change window width/height again
requestAnimationFrame(() => {
this.scrollTo(resumeElement);
this.addEmptyPageIfRequired(); // Try add after layout updated on next frame
// When the user switches pages, there may be a pending scroll that moves to the start or end of the page
// For example, `this.scrollWithinPage(...)` might be triggered when the user presses the prev/next page button
// So, we don't need to do another page scroll here
if (!this.hasDelayedScroll) {
this.scrollTo(resumeElement);
}
});
}
}
@ -1961,7 +2071,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
scrollTo(partSelector: string, timeout: number = 0) {
const element = this.getElementFromXPath(partSelector);
const element = this.getElementFromXPath(partSelector) as HTMLElement;
if (element === null) {
if (!environment.production) {
@ -1975,7 +2085,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const writingStyle = this.writingStyle();
if (layout !== BookPageLayoutMode.Default) {
afterFrame(() => this.scrollService.scrollIntoView(element as HTMLElement, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}}));
afterFrame(() => {
// scrollIntoView method will only scroll to the visible area of the element (not including margin)
// so we need to apply scroll-margin to that element to correctly scroll into it
let margin = window.getComputedStyle(element).margin;
if(margin !== '0px') element.style.scrollMargin = margin;
this.scrollService.scrollIntoView(element, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}})
});
return;
}
@ -2063,6 +2180,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.updateImageSizeTimeout = setTimeout( () => {
this.updateImageSizes();
this.injectImageBookmarkIndicators(true);
// This needs to be checked after the bookmark indicator has been injected or removed
// When switching layout, these indicators may affect the page's total scrollWidth
this.addEmptyPageIfRequired();
}, 200);
this.updateSingleImagePageStyles();
@ -2332,7 +2453,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
redRect.style.top = `${viewport.top}px`;
redRect.style.width = `${viewport.width}px`;
redRect.style.height = `${viewport.height}px`;
redRect.style.border = '1px solid red';
redRect.style.outline = '1px solid red';
redRect.style.pointerEvents = 'none';
redRect.style.zIndex = '1000';
redRect.title = `Width: ${viewport.width}px`;
@ -2357,7 +2478,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
greenRect.style.top = `${viewport.top}px`;
greenRect.style.width = `${margin}px`;
greenRect.style.height = `${viewport.height}px`;
greenRect.style.border = '1px solid green';
greenRect.style.outline = '1px solid green';
greenRect.style.pointerEvents = 'none';
greenRect.style.zIndex = '1000';
greenRect.title = `Width: ${margin}px`;
@ -2376,7 +2497,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
greenRect.style.top = `${viewport.top}px`;
greenRect.style.width = `${margin}px`;
greenRect.style.height = `${viewport.height}px`;
greenRect.style.border = '1px solid green';
greenRect.style.outline = '1px solid green';
greenRect.style.pointerEvents = 'none';
greenRect.style.zIndex = '1000';
greenRect.title = `Width: ${margin}px`;
@ -2437,13 +2558,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return false;
}
logSelectedElement() {
const element = this.getElementFromXPath(this.lastSeenScrollPartPath);
logSelectedElement(color='red') {
const element = this.getElementFromXPath(this.lastSeenScrollPartPath) as HTMLElement | null;
if (element) {
console.log(element);
(element as HTMLElement).style.border = '1px solid red';
element.style.outline = '1px solid ' + color;
setTimeout(() => {
(element as HTMLElement).style.border = '';
element.style.outline = '';
}, 1_000);
}
}
@ -2451,8 +2572,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly Breakpoint = Breakpoint;
protected readonly environment = environment;
protected readonly BookPageLayoutMode = BookPageLayoutMode;
protected readonly WritingStyle = WritingStyle;
protected readonly ReadingDirection = ReadingDirection;
protected readonly PAGING_DIRECTION = PAGING_DIRECTION;
}

View File

@ -1,5 +1,5 @@
.clickable:hover, .clickable:focus {
background-color: var(--list-group-hover-bg-color, --primary-color);
background-color: var(--list-group-hover-bg-color, var(--primary-color));
}
.collection {

View File

@ -1,5 +1,5 @@
.clickable:hover, .clickable:focus {
background-color: var(--list-group-hover-bg-color, --primary-color);
background-color: var(--list-group-hover-bg-color, var(--primary-color));
}
.pill {

View File

@ -267,6 +267,8 @@ export class CardDetailLayoutComponent<TFilter extends number, TSort extends num
let name = '';
if (item.hasOwnProperty('sortName')) {
name = item.sortName;
} else if (item.hasOwnProperty('seriesSortName')) { // Reading List Item
name = item.seriesSortName;
} else if (item.hasOwnProperty('seriesName')) {
name = item.seriesName;
} else if (item.hasOwnProperty('name')) {

View File

@ -59,10 +59,18 @@ export class CarouselReelComponent {
swiper: Swiper | undefined;
get progressChange() {
const totalItems = this.items.length;
const itemsToMove = Math.min(5, totalItems);
const progressPerItem = 1 / totalItems;
return Math.min(0.25, progressPerItem * itemsToMove);
}
nextPage() {
if (this.swiper) {
if (this.swiper.isEnd) return;
this.swiper.setProgress(this.swiper.progress + 0.25, 600);
this.swiper.setProgress(this.swiper.progress + this.progressChange, 600);
this.cdRef.markForCheck();
}
}
@ -70,7 +78,7 @@ export class CarouselReelComponent {
prevPage() {
if (this.swiper) {
if (this.swiper.isBeginning) return;
this.swiper.setProgress(this.swiper.progress - 0.25, 600);
this.swiper.setProgress(this.swiper.progress - this.progressChange, 600);
this.cdRef.markForCheck();
}
}

View File

@ -1,4 +1,4 @@
.text-accent {
font-size: small;
color: var(---accent-text-color);
color: var(--accent-text-color);
}

View File

@ -2,7 +2,7 @@
@if (accountService.currentUser$ | async; as user) {
<div class="{{theme}}" #container>
@if (isLoading) {
@if (isLoading && !disableLoadingIndicator()) {
<div class="loading-message-container">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{t('loading-message')}}

View File

@ -7,7 +7,7 @@ import {
HostListener,
inject,
OnDestroy,
OnInit,
OnInit, signal,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
@ -110,6 +110,10 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
backgroundColor: string = this.themeMap[this.theme].background;
fontColor: string = this.themeMap[this.theme].font;
/**
* True if Preferences.DataSaver is true
*/
disableLoadingIndicator = signal(false);
isLoading: boolean = true;
/**
* How much of the current document is loaded
@ -260,6 +264,9 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
this.backgroundColor = this.themeMap[this.theme].background;
this.fontColor = this.themeMap[this.theme].font; // TODO: Move this to an observable or something
this.disableLoadingIndicator.set(this.user.preferences.dataSaver);
pdfDefaultOptions.disableAutoFetch = this.user.preferences.dataSaver;
this.calcScrollbarNeeded();
this.bookService.getBookInfo(this.chapterId).subscribe(info => {

View File

@ -1,6 +1,5 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs/operators';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
@ -43,7 +42,6 @@ export class ReadingListsComponent implements OnInit {
private router = inject(Router);
private jumpbarService = inject(JumpbarService);
private readonly cdRef = inject(ChangeDetectorRef);
private ngbModal = inject(NgbModal);
private titleService = inject(Title);
protected readonly WikiLink = WikiLink;

View File

@ -24,11 +24,11 @@
@for (opt of options(); track opt.value; let index = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="option--{{index}}" type="checkbox" class="form-check-input"
<input id="{{id()}}--option--{{index}}" type="checkbox" class="form-check-input"
[checked]="isChecked(opt)" (change)="onCheckboxChange(opt, $event)"
[disabled]="isDisabled(opt)"
>
<label class="form-check-label" for="option--{{index}}">{{opt.label}}</label>
<label class="form-check-label" for="{{id()}}--option--{{index}}">{{opt.label}}</label>
@if (opt.colour) {
@let c = opt.colour;
<i class="fas fa-circle" [ngStyle]="{ 'color': `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})` }"></i>

View File

@ -64,6 +64,10 @@ export interface MultiCheckBoxItem<T> {
})
export class SettingMultiCheckBox<T> implements ControlValueAccessor {
/**
* Id to prepend to input id to ensure uniqueness
*/
id = input.required<string>();
/**
* Title to display above the checkboxes
*/

View File

@ -1,7 +1,7 @@
@use '../../../../theme/variables' as theme;
h2 {
color: white;
color: var(--side-nav-header-text-color);
font-weight: bold;
}

View File

@ -68,7 +68,7 @@
}
.side-nav-header {
color: #d5d5d5;
color: var(--pref-side-nav-header-text-color);
font-weight: bold;
margin-left: 5px;
}

View File

@ -84,6 +84,18 @@
</app-setting-switch>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('data-saver-label')" [subtitle]="t('data-saver-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch" id="data-saver"
formControlName="dataSaver" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
</ng-template>
</app-setting-switch>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-item [canEdit]="false" [showEdit]="false" [allowClickEvents]="true"
[title]="t('highlight-bar-label')" [subtitle]="t('highlight-bar-tooltip')">

View File

@ -47,6 +47,7 @@ type UserPreferencesForm = FormGroup<{
locale: FormControl<string>,
bookReaderHighlightSlots: FormArray<FormControl<HighlightSlot>>,
colorScapeEnabled: FormControl<boolean>,
dataSaver: FormControl<boolean>,
aniListScrobblingEnabled: FormControl<boolean>,
wantToReadSync: FormControl<boolean>,
@ -96,9 +97,6 @@ export class ManageUserPreferencesComponent implements OnInit {
loading = signal(true);
ageRatings = signal<AgeRatingDto[]>([]);
libraries = signal<Library[]>([]);
libraryOptions = computed(() => this.libraries().map(l => {
return { label: l.name, value: l.id };
}));
locales: Array<KavitaLocale> = [];
@ -165,6 +163,7 @@ export class ManageUserPreferencesComponent implements OnInit {
locale: this.fb.control<string>(pref.locale || 'en'),
bookReaderHighlightSlots: this.fb.array(pref.bookReaderHighlightSlots.map(s => this.fb.control(s))),
colorScapeEnabled: this.fb.control<boolean>(pref.colorScapeEnabled),
dataSaver: this.fb.control<boolean>(pref.dataSaver),
aniListScrobblingEnabled: this.fb.control<boolean>(pref.aniListScrobblingEnabled),
wantToReadSync: this.fb.control<boolean>(pref.wantToReadSync),

View File

@ -210,6 +210,8 @@
"highlight-bar-tooltip": "These colors are shared between all books",
"colorscape-label": "Use ColorScape",
"colorscape-tooltip": "Global toggle to enable/disable the dynamic gradient feature. Will override theme settings",
"data-saver-label": "Data saver",
"data-saver-tooltip": "Minimizes data usage by preventing automatic prefetching (e.g., PDF reader)",
"kavitaplus-settings-title": "Kavita+",
"anilist-scrobbling-label": "AniList Scrobbling",
@ -782,7 +784,7 @@
"license": {
"title": "Kavita+ License",
"kavita+-warning": "Kavita+ is separate from Kavita. If you uninstall Kavita without unsubscribing, you will be charged.",
"kavita+-warning": "Kavita+ is separate from Kavita. Uninstalling without first cancelling your subscription will continue the billing cycle.",
"manage": "Manage",
"invalid-license-tooltip": "If your subscription has ended, you must email support to get a new subscription created",
"check": "Check",

View File

@ -2,12 +2,13 @@
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
const IP = 'localhost';
// All requests to the backend are proxies through the Angular server, we let the browser pick the host
// This comes with the advantage that you don't need to change anything to test on a different device on the
// network.
export const environment = {
production: false,
apiUrl: 'http://' + IP + ':4200/api/',
hubUrl: 'http://'+ IP + ':4200/hubs/',
apiUrl: '/api/',
hubUrl: '/hubs/',
buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL',
manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss'
};

View File

@ -14,10 +14,18 @@
--bs-btn-active-bg: var(--primary-color-dark-shade);
--bs-btn-active-border-color: var(--primary-color-dark-shade);
&:hover {
i {
color: var(--btn-primary-text-color);
}
&:hover, &:focus-visible {
color: var(--btn-primary-hover-text-color);
background-color: var(--btn-primary-hover-bg-color);
border-color: var(--btn-primary-hover-border-color);
i {
color: var(--btn-primary-hover-text-color);
}
}
}
@ -30,10 +38,18 @@
background-color: var(--btn-outline-primary-bg-color);
border-color: var(--btn-outline-primary-border-color);
&:hover {
i {
color: var(--btn-outline-primary-text-color);
}
&:hover, &:focus-visible {
color: var(--btn-outline-primary-hover-text-color) !important;
background-color: var(--btn-outline-primary-hover-bg-color) !important;
border-color: var(--btn-outline-primary-hover-border-color) !important;
i {
color: var(--btn-outline-primary-hover-text-color) !important;
}
}
}
@ -82,7 +98,11 @@
background-color: var(--btn-secondary-outline-bg-color);
border-color: var(--btn-secondary-outline-border-color);
&:hover {
i {
color: var(--btn-secondary-outline-text-color);
}
&:hover, &:focus-visible {
--bs-btn-color: var(--btn-secondary-outline-hover-text-color);
--bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color);
--bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color);
@ -94,6 +114,10 @@
box-shadow: inset 0px -2px 0px 0px var(--btn-secondary-outline-text-color);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
i {
color: var(--btn-secondary-outline-hover-text-color);
}
}
}
@ -103,10 +127,18 @@
background-color: var(--btn-danger-outline-bg-color);
border-color: var(--btn-danger-outline-border-color);
&:hover {
i {
color: var(--btn-danger-outline-text-color);
}
&:hover, &:focus-visible {
color: var(--btn-danger-outline-hover-text-color);
background-color: var(--btn-danger-outline-hover-bg-color);
border-color: var(--btn-danger-outline-hover-border-color);
i {
color: var(--btn-danger-outline-hover-text-color);
}
}
}
@ -132,6 +164,9 @@
background-color: var(--btn-disabled-bg-color);
color: var(--btn-disabled-text-color);
border-color: var(--btn-disabled-border-color);
i {
color: var(--btn-disabled-text-color);
}
}
button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :disabled {
@ -144,14 +179,14 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
color: var(--body-text-color) !important;
}
&:hover, &:focus {
&:hover, &:focus, &:focus-visible {
color: var(--primary-color);
}
}
.btn:focus, .btn:active, .btn:active:focus {
box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important;
box-shadow: 0 0 0 0 var(--btn-focus-boxshadow-color) !important;
}
@ -160,11 +195,15 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
color: var(--body-text-color);
border: none;
i {
color: var(--body-text-color);
}
&:disabled {
--bs-btn-disabled-bg: transparent;
}
&:hover, &:focus {
&:hover, &:focus, &:focus-visible {
color: var(--body-text-color);
border: none;
}
@ -178,14 +217,26 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
.btn-primary-text {
color: var(--btn-primary-text-text-color);
i {
color: var(--btn-primary-text-text-color);
}
}
.btn-secondary-text {
color: var(--btn-secondary-text-text-color);
i {
color: var(--btn-secondary-text-text-color);
}
}
.btn-danger-text {
color: var(--btn-danger-text-text-color);
i {
color: var(--btn-danger-text-text-color);
}
}
@ -194,6 +245,10 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
color: var(--btn-secondary-text-color);
background-color: var(--btn-secondary-bg-color);
border-color: var(--btn-secondary-border-color);
i {
color: var(--btn-secondary-text-color);
}
}
.btn.btn-secondary.alt {
@ -205,15 +260,17 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
font-weight: var(--btn-secondary-font-weight);
}
&:focus {
&:focus, &:focus-visible {
background-color: var(--btn-alt-focus-bg-color);
box-shadow: 0 0 0 0.05rem var(--btn-alt-focus-boxshadow-color);
font-weight: var(--btn-secondary-font-weight);
}
}
button i.fa {
color: var(--btn-fa-icon-color);
button {
i.fa, i.fa-regular {
color: var(--btn-fa-icon-color);
}
}
.btn-check:focus + .btn, .btn:focus {
@ -230,7 +287,6 @@ button i.fa {
border: none;
padding: 0;
margin: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
@ -239,10 +295,3 @@ button i.fa {
outline: none;
}
//
//.btn-primary .btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {
// --bs-btn-active-bg: var(--primary-color-dark-shade);
// --bs-btn-active-border-color: var(--primary-color-dark-shade);
//}

View File

@ -38,6 +38,10 @@ $image-height: 232.91px;
$image-filter-height: 160px;
$image-width: 160px;
.card-title {
--bs-card-title-color: var(--card-title-text-color);
}
.card-item-container {
.card {
max-width: $image-width;
@ -76,12 +80,19 @@ $image-width: 160px;
height: $image-filter-height;
}
}
}
& .card-title {
color: var(--card-overlay-text-color);
}
& + .card-body {
color: var(--card-overlay-text-color);
}
}
.card-body {
padding: 0 5px !important;
background-color: rgba(0,0,0,0.7);
background-color: var(--card-body-bg-color);
border-width: var(--card-border-width);
border-style: var(--card-border-style);
border-color: var(--card-border-color);

View File

@ -3,14 +3,16 @@ input:not([type="range"]), .form-control {
color: var(--input-text-color);
border-color: var(--input-border-color);
&:focus {
&:focus:not(:checked) {
border-color: var(--input-focused-border-color);
background-color: var(--input-bg-color);
color: var(--input-text-color);
box-shadow: 0 0 0 .25rem var(--input-focus-boxshadow-color);
}
&:read-only {
// Checkboxes are selected by the :read-only pseudo-class, even when they're editable
// See https://developer.mozilla.org/en-US/docs/Web/CSS/:read-only
&:read-only:not([type="checkbox"]) {
background-color: var(--input-bg-readonly-color);
cursor: initial;
}

View File

@ -14,11 +14,6 @@
box-shadow: 0 0 0 0.25rem var(--input-focus-boxshadow-color);
}
&:read-only {
background-color: var(--input-bg-readonly-color);
cursor: initial;
}
&:disabled {
cursor: not-allowed;
pointer-events: none;

View File

@ -114,8 +114,7 @@
}
.active-highlight {
background-color: #2f2f2f;
background-color: rgb(255 255 255 / 9%);
background-color: var(--side-nav-item-color);
width: 0.25rem;
height: 100%;
position: absolute;
@ -203,7 +202,7 @@
padding-left: 1.125rem;
.side-nav-header {
color: #ffffff;
color: var(--side-nav-header-text-color);
font-size: 1rem;
margin-left: unset;
@ -227,7 +226,7 @@
text-align: unset;
margin-left: 0.75rem;
font-size: 0.9rem;
color: #999999;
color: var(--side-nav-text-color);
div {
display: flex;
@ -238,6 +237,11 @@
}
}
&:hover {
.side-nav-text {
color: var(--side-nav-hover-text-color);
}
}
.card-actions {
display: none;
}

View File

@ -244,7 +244,8 @@
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
--side-nav-hover-text-color: white;
--side-nav-hover-bg-color: black;
--side-nav-text-color: hsla(0,0%,100%,.85);
--side-nav-text-color: hsla(0, 0%, 100%, .85);
--side-nav-header-text-color: white;
--side-nav-border-radius: 3px;
--side-nav-border: none;
--side-nav-border-closed: none;
@ -256,8 +257,10 @@
--side-nav-item-active-text-color: #fff;
--side-nav-active-bg-color: transparent;
--side-nav-overlay-color: var(--elevation-layer11-dark);
--side-nav-item-color: rgb(255 255 255 / 9%);
--side-nav-item-closed-color: var(--elevation-layer10);
--side-nav-item-closed-hover-color: white;
--pref-side-nav-header-text-color: #d5d5d5;
/* List items */
--list-group-item-text-color: var(--body-text-color);
@ -353,6 +356,11 @@
--card-overlay-bg-color: rgba(0, 0, 0, 0);
--card-overlay-hover-bg-color: rgba(30,30,30,.6);
--card-progress-triangle-size: 28px;
--card-body-bg-color: rgba(0,0,0,0.7);
--card-title-text-color: var(--card-text-color);
--card-overlay-text-color: var(--card-text-color);
--card-hover-text-color: var(--card-text-color);
--card-hover-bg-color: #3a3a3a;
/* Slider */
--slider-text-color: white;