diff --git a/booklore-api/src/main/java/org/booklore/config/security/filter/SharedArrayBufferHeaderFilter.java b/booklore-api/src/main/java/org/booklore/config/security/filter/SharedArrayBufferHeaderFilter.java new file mode 100644 index 000000000..362e2a48a --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/config/security/filter/SharedArrayBufferHeaderFilter.java @@ -0,0 +1,29 @@ +package org.booklore.config.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Enables SharedArrayBuffer by setting Cross-Origin isolation headers. + * Required by pdfium WASM (Emscripten-compiled with thread support) used by EmbedPDF. + * Without these headers the WASM module stalls on instantiation. + */ +@Component +@Order(1) +public class SharedArrayBufferHeaderFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Embedder-Policy", "credentialless"); + filterChain.doFilter(request, response); + } +} diff --git a/booklore-api/src/main/java/org/booklore/controller/BookController.java b/booklore-api/src/main/java/org/booklore/controller/BookController.java index d9bf2d94b..d8fad4de4 100644 --- a/booklore-api/src/main/java/org/booklore/controller/BookController.java +++ b/booklore-api/src/main/java/org/booklore/controller/BookController.java @@ -153,6 +153,21 @@ public void getBookContent( bookService.streamBookContent(bookId, bookType, request, response); } + @Operation(summary = "Replace book content", description = "Overwrite the primary PDF file for a book with the uploaded content. Used by the document viewer to persist annotation changes.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Book content replaced successfully"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) + @PutMapping("/{bookId}/content") + @CheckBookAccess(bookIdParam = "bookId") + public ResponseEntity replaceBookContent( + @Parameter(description = "ID of the book") @PathVariable long bookId, + @Parameter(description = "Optional book type for alternative format") @RequestParam(required = false) String bookType, + HttpServletRequest request) throws java.io.IOException { + bookService.replaceBookContent(bookId, bookType, request.getInputStream()); + return ResponseEntity.noContent().build(); + } + @Operation(summary = "Download book", description = "Download the book file. Requires download permission or admin.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "Book downloaded successfully"), diff --git a/booklore-api/src/main/java/org/booklore/controller/CbxReaderController.java b/booklore-api/src/main/java/org/booklore/controller/CbxReaderController.java index 9d9b53022..9f1f82668 100644 --- a/booklore-api/src/main/java/org/booklore/controller/CbxReaderController.java +++ b/booklore-api/src/main/java/org/booklore/controller/CbxReaderController.java @@ -1,5 +1,6 @@ package org.booklore.controller; +import org.booklore.model.dto.response.CbxPageDimension; import org.booklore.model.dto.response.CbxPageInfo; import org.booklore.service.reader.CbxReaderService; import io.swagger.v3.oas.annotations.Operation; @@ -36,4 +37,13 @@ public List getPageInfo( @Parameter(description = "Optional book type for alternative format (e.g., PDF, CBX)") @RequestParam(required = false) String bookType) { return cbxReaderService.getPageInfo(bookId, bookType); } + + @Operation(summary = "Get page dimensions for a CBX book", description = "Retrieve width, height, and wide flag for each page in a CBX book.") + @ApiResponse(responseCode = "200", description = "Page dimensions returned successfully") + @GetMapping("/{bookId}/page-dimensions") + public List getPageDimensions( + @Parameter(description = "ID of the book") @PathVariable Long bookId, + @Parameter(description = "Optional book type for alternative format (e.g., PDF, CBX)") @RequestParam(required = false) String bookType) { + return cbxReaderService.getPageDimensions(bookId, bookType); + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/org/booklore/model/dto/PdfViewerPreferences.java b/booklore-api/src/main/java/org/booklore/model/dto/PdfViewerPreferences.java index 5a7e36cb6..1ba66a487 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/PdfViewerPreferences.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/PdfViewerPreferences.java @@ -13,4 +13,5 @@ public class PdfViewerPreferences { private Long bookId; private String zoom; private String spread; + private Boolean isDarkTheme; } \ No newline at end of file diff --git a/booklore-api/src/main/java/org/booklore/model/dto/response/CbxPageDimension.java b/booklore-api/src/main/java/org/booklore/model/dto/response/CbxPageDimension.java new file mode 100644 index 000000000..0698b3c21 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/response/CbxPageDimension.java @@ -0,0 +1,17 @@ +package org.booklore.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CbxPageDimension { + private int pageNumber; + private int width; + private int height; + private boolean wide; +} diff --git a/booklore-api/src/main/java/org/booklore/model/entity/PdfViewerPreferencesEntity.java b/booklore-api/src/main/java/org/booklore/model/entity/PdfViewerPreferencesEntity.java index 78b276faf..53d50ed48 100644 --- a/booklore-api/src/main/java/org/booklore/model/entity/PdfViewerPreferencesEntity.java +++ b/booklore-api/src/main/java/org/booklore/model/entity/PdfViewerPreferencesEntity.java @@ -29,4 +29,7 @@ public class PdfViewerPreferencesEntity { @Column(name = "spread") private String spread; + + @Column(name = "is_dark_theme") + private Boolean isDarkTheme; } \ No newline at end of file diff --git a/booklore-api/src/main/java/org/booklore/model/enums/CbxPageScrollMode.java b/booklore-api/src/main/java/org/booklore/model/enums/CbxPageScrollMode.java index 31d2acac2..346d1749f 100644 --- a/booklore-api/src/main/java/org/booklore/model/enums/CbxPageScrollMode.java +++ b/booklore-api/src/main/java/org/booklore/model/enums/CbxPageScrollMode.java @@ -2,5 +2,6 @@ public enum CbxPageScrollMode { PAGINATED, - INFINITE + INFINITE, + LONG_STRIP } diff --git a/booklore-api/src/main/java/org/booklore/service/book/BookService.java b/booklore-api/src/main/java/org/booklore/service/book/BookService.java index 5506a812e..5527c0de9 100644 --- a/booklore-api/src/main/java/org/booklore/service/book/BookService.java +++ b/booklore-api/src/main/java/org/booklore/service/book/BookService.java @@ -43,6 +43,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.*; import java.util.stream.Collectors; @@ -205,6 +206,7 @@ public BookViewerSettings getBookViewerSetting(long bookId, long bookFileId) { .bookId(bookId) .zoom(pdfPref.getZoom()) .spread(pdfPref.getSpread()) + .isDarkTheme(pdfPref.getIsDarkTheme()) .build())); newPdfViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) .ifPresent(pdfPref -> settingsBuilder.newPdfSettings(NewPdfViewerPreferences.builder() @@ -382,6 +384,25 @@ public void streamBookContent(long bookId, String bookType, HttpServletRequest r fileStreamingService.streamWithRangeSupport(path, contentType, request, response); } + public void replaceBookContent(long bookId, String bookType, java.io.InputStream content) throws IOException { + BookEntity bookEntity = bookRepository.findByIdWithBookFiles(bookId) + .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + + Path filePath; + if (bookType != null) { + BookFileType requestedType = BookFileType.valueOf(bookType.toUpperCase()); + BookFileEntity bookFile = bookEntity.getBookFiles().stream() + .filter(bf -> bf.getBookType() == requestedType) + .findFirst() + .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("No file of type " + bookType + " found for book")); + filePath = bookFile.getFullFilePath(); + } else { + filePath = FileUtils.getBookFullPath(bookEntity); + } + + Files.copy(content, filePath, StandardCopyOption.REPLACE_EXISTING); + } + @Transactional public ResponseEntity deleteBooks(Set ids) { BookLoreUser user = authenticationService.getAuthenticatedUser(); diff --git a/booklore-api/src/main/java/org/booklore/service/book/BookUpdateService.java b/booklore-api/src/main/java/org/booklore/service/book/BookUpdateService.java index 062d3eebc..b61ab1dea 100644 --- a/booklore-api/src/main/java/org/booklore/service/book/BookUpdateService.java +++ b/booklore-api/src/main/java/org/booklore/service/book/BookUpdateService.java @@ -120,6 +120,7 @@ private void updatePdfViewerSettings(long bookId, Long userId, BookViewerSetting PdfViewerPreferences pdfSettings = settings.getPdfSettings(); prefs.setZoom(pdfSettings.getZoom()); prefs.setSpread(pdfSettings.getSpread()); + prefs.setIsDarkTheme(pdfSettings.getIsDarkTheme()); pdfViewerPreferencesRepository.save(prefs); } diff --git a/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java b/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java index f808851ba..46e03c431 100644 --- a/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java +++ b/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java @@ -9,6 +9,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.pdfbox.io.IOUtils; import org.booklore.exception.ApiError; +import org.booklore.model.dto.response.CbxPageDimension; import org.booklore.model.dto.response.CbxPageInfo; import org.booklore.model.entity.BookEntity; import org.booklore.model.entity.BookFileEntity; @@ -19,6 +20,11 @@ import org.booklore.util.UnrarHelper; import org.springframework.stereotype.Service; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -113,6 +119,63 @@ public List getPageInfo(Long bookId, String bookType) { } } + public List getPageDimensions(Long bookId) { + return getPageDimensions(bookId, null); + } + + public List getPageDimensions(Long bookId, String bookType) { + Path cbxPath = getBookPath(bookId, bookType); + try { + CachedArchiveMetadata metadata = getCachedMetadata(cbxPath); + List imageEntries = metadata.imageEntries; + List dimensions = new ArrayList<>(); + for (int i = 0; i < imageEntries.size(); i++) { + String entryName = imageEntries.get(i); + CbxPageDimension dim = readEntryDimension(cbxPath, entryName, metadata, i + 1); + dimensions.add(dim); + } + return dimensions; + } catch (IOException e) { + log.error("Failed to read page dimensions for book {}", bookId, e); + throw ApiError.FILE_READ_ERROR.createException("Failed to read page dimensions: " + e.getMessage()); + } + } + + private CbxPageDimension readEntryDimension(Path cbxPath, String entryName, CachedArchiveMetadata metadata, int pageNumber) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + streamEntryFromArchive(cbxPath, entryName, baos, metadata); + byte[] imageBytes = baos.toByteArray(); + try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(imageBytes))) { + Iterator readers = ImageIO.getImageReaders(iis); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + return CbxPageDimension.builder() + .pageNumber(pageNumber) + .width(width) + .height(height) + .wide(width > height) + .build(); + } finally { + reader.dispose(); + } + } + } + } catch (IOException e) { + log.warn("Failed to read dimensions for page {} (entry: {}): {}", pageNumber, entryName, e.getMessage()); + } + return CbxPageDimension.builder() + .pageNumber(pageNumber) + .width(0) + .height(0) + .wide(false) + .build(); + } + private String extractDisplayName(String entryPath) { String fileName = baseName(entryPath); int lastDotIndex = fileName.lastIndexOf('.'); diff --git a/booklore-api/src/main/java/org/booklore/service/user/DefaultUserSettingsProvider.java b/booklore-api/src/main/java/org/booklore/service/user/DefaultUserSettingsProvider.java index accea41cc..08b7fd577 100644 --- a/booklore-api/src/main/java/org/booklore/service/user/DefaultUserSettingsProvider.java +++ b/booklore-api/src/main/java/org/booklore/service/user/DefaultUserSettingsProvider.java @@ -60,7 +60,7 @@ private BookLoreUser.UserSettings.PerBookSetting buildDefaultPerBookSetting() { private BookLoreUser.UserSettings.PdfReaderSetting buildDefaultPdfReaderSetting() { return BookLoreUser.UserSettings.PdfReaderSetting.builder() - .pageSpread("odd") + .pageSpread("off") .pageZoom("page-fit") .build(); } diff --git a/booklore-api/src/main/resources/db/migration/V134__Add_is_dark_theme_to_pdf_viewer_preference.sql b/booklore-api/src/main/resources/db/migration/V134__Add_is_dark_theme_to_pdf_viewer_preference.sql new file mode 100644 index 000000000..1f6db76ea --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V134__Add_is_dark_theme_to_pdf_viewer_preference.sql @@ -0,0 +1 @@ +ALTER TABLE pdf_viewer_preference ADD COLUMN is_dark_theme BOOLEAN DEFAULT TRUE; diff --git a/booklore-api/src/main/resources/db/migration/V135__Change_pdf_default_spread_to_off.sql b/booklore-api/src/main/resources/db/migration/V135__Change_pdf_default_spread_to_off.sql new file mode 100644 index 000000000..8fd65d038 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V135__Change_pdf_default_spread_to_off.sql @@ -0,0 +1,10 @@ +-- Change default PDF page spread from 'odd' to 'off' (single page view) +UPDATE user_settings +SET setting_value = REPLACE(setting_value, '"pageSpread":"odd"', '"pageSpread":"off"') +WHERE setting_key = 'PDF_READER_SETTING' + AND setting_value LIKE '%"pageSpread":"odd"%'; + +-- Update existing per-book PDF viewer preferences +UPDATE pdf_viewer_preference +SET spread = 'off' +WHERE spread = 'odd'; diff --git a/frontend/.yarnrc.yml b/frontend/.yarnrc.yml new file mode 100644 index 000000000..7bbc4ab49 --- /dev/null +++ b/frontend/.yarnrc.yml @@ -0,0 +1,4 @@ +nodeLinker: node-modules +nmMode: hardlinks-local +enableGlobalCache: false +cacheFolder: .yarn/cache diff --git a/frontend/angular.json b/frontend/angular.json index 11eab8971..0594c7aea 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -47,6 +47,16 @@ "glob": "**/*", "input": "libs/foliate-js", "output": "/assets/foliate/" + }, + { + "glob": "**/*", + "input": "node_modules/@embedpdf/pdfium/dist/", + "output": "/assets/pdfium/" + }, + { + "glob": "**/*", + "input": "node_modules/@embedpdf/snippet/dist/", + "output": "/assets/embedpdf/" } ], "styles": [ @@ -98,6 +108,12 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + "headers": { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless" + } + }, "configurations": { "production": { "buildTarget": "grimmory:build:production" @@ -135,4 +151,4 @@ ], "analytics": false } -} +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index d82dbef06..eab20ce6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,8 @@ "@angular/platform-browser-dynamic": "^21.2.6", "@angular/router": "^21.2.6", "@angular/service-worker": "^21.2.6", + "@embedpdf/pdfium": "^2.11.1", + "@embedpdf/snippet": "^2.11.1", "@iharbeck/ngx-virtual-scroller": "^20.0.0", "@jsverse/transloco": "^8.2.1", "@primeuix/themes": "^2.0.3", diff --git a/frontend/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts b/frontend/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts index f624e7e0d..1e5c7f6f3 100644 --- a/frontend/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts +++ b/frontend/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts @@ -73,6 +73,13 @@ export class BookFilterComponent implements OnInit, OnDestroy { readonly filterLabelKeys = FILTER_LABEL_KEYS; + private readonly userSettingsEffect = effect(() => { + const user = this.userService.currentUser(); + if (!user) return; + this._visibleFilters = user.userSettings.visibleFilters ?? [...DEFAULT_VISIBLE_FILTERS]; + this.updateVisibleFilterTypes(); + }); + getFilterLabel(type: FilterType): string { const key = this.filterLabelKeys[type]; return key ? this.t.translate(key) : type; @@ -99,7 +106,6 @@ export class BookFilterComponent implements OnInit, OnDestroy { ngOnInit(): void { this.updateVisibleFilterTypes(); this.updateExpandedPanels(); - this.subscribeToUserSettings(); this.subscribeToReset(); } @@ -164,15 +170,6 @@ export class BookFilterComponent implements OnInit, OnDestroy { return String(value ?? ''); } - private subscribeToUserSettings(): void { - effect(() => { - const user = this.userService.currentUser(); - if (!user) return; - this._visibleFilters = user.userSettings.visibleFilters ?? [...DEFAULT_VISIBLE_FILTERS]; - this.updateVisibleFilterTypes(); - }); - } - private updateVisibleFilterTypes(): void { this.visibleFilterTypes = this._visibleFilters.filter( vf => this.filterTypes.includes(vf as FilterType) diff --git a/frontend/src/app/features/book/model/book.model.ts b/frontend/src/app/features/book/model/book.model.ts index 1ab3e3c8e..d3386d6a2 100644 --- a/frontend/src/app/features/book/model/book.model.ts +++ b/frontend/src/app/features/book/model/book.model.ts @@ -1,5 +1,5 @@ import {Shelf} from './shelf.model'; -import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrollMode, NewPdfReaderSetting} from '../../settings/user-management/user.service'; +import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageSplitOption, CbxPageViewMode, CbxScrollMode, NewPdfReaderSetting} from '../../settings/user-management/user.service'; import {BookReview} from '../components/book-reviews/book-review-service'; import {ZoomType} from 'ngx-extended-pdf-viewer'; @@ -305,6 +305,7 @@ export interface MetadataUpdateWrapper { export interface PdfViewerSetting { zoom: ZoomType; spread: 'off' | 'even' | 'odd'; + isDarkTheme?: boolean; } export interface EpubViewerSetting { @@ -339,6 +340,11 @@ export interface CbxViewerSetting { fitMode: CbxFitMode; scrollMode?: CbxScrollMode; backgroundColor?: CbxBackgroundColor; + pageSplitOption?: CbxPageSplitOption; + brightness?: number; + emulateBook?: boolean; + clickToPaginate?: boolean; + autoCloseMenu?: boolean; } export interface BookSetting { diff --git a/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.html b/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.html index fa9e92d2d..c3202064a 100644 --- a/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.html +++ b/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.html @@ -1,133 +1,154 @@ -
- +
+ @if (showQuickSettings) { - + } @if (isCurrentPageBookmarked) { -
- - - -
+
+ + + +
} @if (showNoteDialog) { - + } @if (showShortcutsHelp) { - + } @if (isSlideshowActive) { -
+
}
-
+ [class.bg-white]="backgroundColor === CbxBackgroundColor.WHITE" [class.emulate-book]="emulateBook && isTwoPageView" + [style.filter]="brightness < 100 ? 'brightness(' + brightness + '%)' : null" (scroll)="onScroll($event)"> @if (!isLoading) { - @if (pages.length > 0) { - @if (scrollMode === CbxScrollMode.PAGINATED) { -
- @if (isPageTransitioning && previousImageUrls.length > 0) { -
- @for (url of previousImageUrls; track url) { - Previous Page - } -
- } -
- @for (url of currentImageUrls; track url; let i = $index) { - Page Image - } -
-
- @if (isAtLastPage && nextBookInSeries) { -
- -
- } + @if (pages.length > 0) { + @if (scrollMode === CbxScrollMode.PAGINATED) { +
+ @if (isPageTransitioning && previousImageUrls.length > 0) { +
+ @for (url of previousImageUrls; track url) { + Previous Page + } +
+ } +
+ @if (shouldUseCanvasRenderer()) { + + } @else { -
- @for (pageIdx of infiniteScrollPages; track pageIdx; let i = $index) { - Page {{ pageIdx + 1 }} - } - @if (isLoadingMore) { -
- -
- } - @if (infiniteScrollPages.length > 0 && infiniteScrollPages[infiniteScrollPages.length - 1] >= pages.length - 1 && nextBookInSeries) { -
- -
- } -
+ @for (url of currentImageUrls; track url; let i = $index) { + Page Image + } } - } @else { -
-

{{ 'readerCbx.reader.noPagesAvailable' | transloco }}

-
+
+
+ @if (isAtLastPage && nextBookInSeries) { +
+ +
+ } + } @else if (scrollMode === CbxScrollMode.LONG_STRIP) { +
+ @for (item of longStripImages; track item.page) { + Page {{ item.page + 1 }} + } + @if (longStripImages.length > 0 && longStripImages[longStripImages.length - 1].page >= pages.length - 1 && + nextBookInSeries) { +
+ +
} +
} @else { -
-
-
-

{{ 'readerCbx.reader.loadingBook' | transloco }}

-
+
+ @for (pageIdx of infiniteScrollPages; track pageIdx; let i = $index) { + Page {{ pageIdx + 1 }} + } + @if (isLoadingMore) { +
+ +
+ } + @if (infiniteScrollPages.length > 0 && infiniteScrollPages[infiniteScrollPages.length - 1] >= pages.length - 1 && + nextBookInSeries) { +
+
+ } +
+ } + + @if (clickToPaginate) { +
+
+ +
+
+
+
+ +
+
+ } + } @else { +
+

{{ 'readerCbx.reader.noPagesAvailable' | transloco }}

+
+ } + } @else { +
+
+
+

{{ 'readerCbx.reader.loadingBook' | transloco }}

+
+
}
-
+
\ No newline at end of file diff --git a/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.scss b/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.scss index 4afc25a2a..d203571ee 100644 --- a/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.scss +++ b/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.scss @@ -19,6 +19,7 @@ // RTL reading mode - reverse page order in two-page view &.rtl-reading { .two-page-view { + .pages-wrapper, .previous-page-layer, .current-page-layer { @@ -45,8 +46,13 @@ } @keyframes slideshow-fill { - from { width: 0; } - to { width: 100%; } + from { + width: 0; + } + + to { + width: 100%; + } } .magnifier-lens { @@ -184,6 +190,7 @@ } &.fit-width { + .pages-wrapper, .previous-page-layer, .current-page-layer { @@ -201,6 +208,7 @@ } &.two-page-view { + .pages-wrapper, .previous-page-layer, .current-page-layer { @@ -217,6 +225,7 @@ } &.fit-height { + .pages-wrapper, .previous-page-layer, .current-page-layer { @@ -401,70 +410,33 @@ } } - // Long Strip / Webtoon mode - no gaps between pages + // Long-strip (webtoon) mode — gapless vertical strip, always fit-width &.long-strip { overflow: hidden auto; align-items: flex-start; touch-action: pan-y; - -webkit-overflow-scrolling: touch; overscroll-behavior: contain; .long-strip-wrapper { display: flex; flex-direction: column; align-items: center; - gap: 0; width: 100%; - padding: 0; - } - .page-image { - box-shadow: none; - border-radius: 0; - transition: none; - display: block; - - &:hover { - transform: none; - } + // No gap, no padding — gapless vertical strip } - &.fit-width .page-image { + .long-strip-image { + display: block; width: 100%; height: auto; - } - - &.fit-height .page-image { - width: auto; - height: auto; max-width: 100%; - } - - &.fit-actual-size { - overflow-x: auto; - - .long-strip-wrapper { - width: max-content; - min-width: 100%; - } - - .page-image { - width: auto; - height: auto; - } - } + border: 0; + margin: 0; + padding: 0; - &.fit-page .page-image, - &.fit-auto .page-image { - max-width: 100%; - width: auto; - height: auto; - } - - .loading-more { - padding: 2rem; - display: flex; - justify-content: center; + // Hardware acceleration (Kavita pattern) + transform: translate3d(0, 0, 0); } } @@ -550,7 +522,7 @@ } } -@media (width <= 768px) { +@media (width <=768px) { .comic-reader-container { .slideshow-progress.above-footer { bottom: 48px; @@ -568,8 +540,13 @@ min-width: 240px; padding: 1.5rem 2rem; - .action-icon { font-size: 2rem; } - .action-text { font-size: 1rem; } + .action-icon { + font-size: 2rem; + } + + .action-text { + font-size: 1rem; + } .book-title { font-size: 0.85rem; @@ -618,3 +595,90 @@ transform: rotate(360deg); } } + +// Click-to-paginate overlay +.click-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; + + &.visible { + opacity: 1; + } + + .click-zone { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + + .click-zone-prev { + width: 40%; + background: rgb(255 255 255 / 3%); + } + + .click-zone-menu { + width: 20%; + } + + .click-zone-next { + width: 40%; + background: rgb(255 255 255 / 3%); + } + + .zone-arrow { + font-size: 2rem; + color: rgb(255 255 255 / 30%); + opacity: 0; + transition: opacity 0.3s ease; + } + + &.visible .zone-arrow { + opacity: 1; + } + + // RTL: swap prev/next zones visually + &.rtl-overlay { + flex-direction: row-reverse; + } + + // Disable pointer events when magnifier is active so elementFromPoint can reach images + &.magnifier-disabled { + pointer-events: none; + + .click-zone { + pointer-events: none; + } + } +} + +// Emulate book mode spine shadow in two-page view +.image-container.emulate-book { + + .pages-wrapper, + .current-page-layer { + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 0; + z-index: 2; + pointer-events: none; + box-shadow: + -4px 0 12px rgb(0 0 0 / 40%), + 4px 0 12px rgb(0 0 0 / 40%), + -2px 0 6px rgb(0 0 0 / 20%), + 2px 0 6px rgb(0 0 0 / 20%); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.ts b/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.ts index db8c1fcd1..3c0e8ece9 100644 --- a/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.ts +++ b/frontend/src/app/features/readers/cbx-reader/cbx-reader.component.ts @@ -1,31 +1,35 @@ -import {Component, effect, ElementRef, HostListener, inject, OnDestroy, OnInit, ViewChild} from '@angular/core'; -import {ActivatedRoute, Router} from '@angular/router'; -import {CommonModule} from '@angular/common'; -import {forkJoin, from, Subject} from 'rxjs'; -import {debounceTime, map, switchMap, takeUntil} from 'rxjs/operators'; -import {PageTitleService} from "../../../shared/service/page-title.service"; -import {CbxReaderService} from '../../book/service/cbx-reader.service'; -import {BookService} from '../../book/service/book.service'; -import {CbxBackgroundColor, CbxFitMode, CbxMagnifierLensSize, CbxMagnifierZoom, CbxPageSpread, CbxPageViewMode, CbxScrollMode, CbxReadingDirection, CbxSlideshowInterval, UserService} from '../../settings/user-management/user.service'; -import {MessageService} from 'primeng/api'; -import {TranslocoService, TranslocoPipe} from '@jsverse/transloco'; -import {Book, BookSetting, BookType} from '../../book/model/book.model'; -import {ProgressSpinner} from 'primeng/progressspinner'; -import {FormsModule} from "@angular/forms"; -import {ReadingSessionService} from '../../../shared/service/reading-session.service'; -import {ReaderHeaderFooterVisibilityManager} from '../ebook-reader'; - -import {CbxHeaderComponent} from './layout/header/cbx-header.component'; -import {CbxHeaderService} from './layout/header/cbx-header.service'; -import {CbxSidebarComponent} from './layout/sidebar/cbx-sidebar.component'; -import {CbxSidebarService} from './layout/sidebar/cbx-sidebar.service'; -import {CbxFooterComponent} from './layout/footer/cbx-footer.component'; -import {CbxFooterService} from './layout/footer/cbx-footer.service'; -import {CbxQuickSettingsComponent} from './layout/quick-settings/cbx-quick-settings.component'; -import {CbxQuickSettingsService} from './layout/quick-settings/cbx-quick-settings.service'; -import {CbxNoteDialogComponent, CbxNoteDialogData, CbxNoteDialogResult} from './dialogs/cbx-note-dialog.component'; -import {CbxShortcutsHelpComponent} from './dialogs/cbx-shortcuts-help.component'; -import {BookNoteV2} from '../../../shared/service/book-note-v2.service'; +import { Component, effect, ElementRef, HostListener, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { forkJoin, from, Subject } from 'rxjs'; +import { debounceTime, map, switchMap, takeUntil } from 'rxjs/operators'; +import { PageTitleService } from "../../../shared/service/page-title.service"; +import { CbxReaderService } from '../../book/service/cbx-reader.service'; +import { BookService } from '../../book/service/book.service'; +import { CbxBackgroundColor, CbxFitMode, CbxMagnifierLensSize, CbxMagnifierZoom, CbxPageSpread, CbxPageSplitOption, CbxPageViewMode, CbxScrollMode, CbxReadingDirection, CbxSlideshowInterval, UserService } from '../../settings/user-management/user.service'; +import { CbxPageDimensionService, DoublePairs } from './core/cbx-page-dimension.service'; +import { CbxPageDimension } from './models/cbx-page-dimension.model'; +import { MessageService } from 'primeng/api'; +import { TranslocoService, TranslocoPipe } from '@jsverse/transloco'; +import { Book, BookSetting, BookType } from '../../book/model/book.model'; +import { ProgressSpinner } from 'primeng/progressspinner'; +import { FormsModule } from "@angular/forms"; +import { ReadingSessionService } from '../../../shared/service/reading-session.service'; +import { WakeLockService } from '../../../shared/service/wake-lock.service'; +import { ReaderHeaderFooterVisibilityManager } from '../ebook-reader'; + +import { CbxHeaderComponent } from './layout/header/cbx-header.component'; +import { CbxHeaderService } from './layout/header/cbx-header.service'; +import { CbxSidebarComponent } from './layout/sidebar/cbx-sidebar.component'; +import { CbxSidebarService } from './layout/sidebar/cbx-sidebar.service'; +import { CbxFooterComponent } from './layout/footer/cbx-footer.component'; +import { CbxFooterService } from './layout/footer/cbx-footer.service'; +import { CbxQuickSettingsComponent } from './layout/quick-settings/cbx-quick-settings.component'; +import { CbxQuickSettingsService } from './layout/quick-settings/cbx-quick-settings.service'; +import { CbxNoteDialogComponent, CbxNoteDialogData, CbxNoteDialogResult } from './dialogs/cbx-note-dialog.component'; +import { CbxShortcutsHelpComponent } from './dialogs/cbx-shortcuts-help.component'; +import { CanvasRendererComponent } from './renderers/canvas-renderer.component'; +import { BookNoteV2 } from '../../../shared/service/book-note-v2.service'; @Component({ @@ -41,7 +45,8 @@ import {BookNoteV2} from '../../../shared/service/book-note-v2.service'; CbxFooterComponent, CbxQuickSettingsComponent, CbxNoteDialogComponent, - CbxShortcutsHelpComponent + CbxShortcutsHelpComponent, + CanvasRendererComponent ], providers: [ CbxHeaderService, @@ -81,6 +86,20 @@ export class CbxReaderComponent implements OnInit, OnDestroy { preloadCount: number = 3; isLoadingMore: boolean = false; + // Long-strip state (Kavita-inspired webtoon reader) + private static readonly LONG_STRIP_BUFFER = 5; + longStripImages: { src: string; page: number }[] = []; + private longStripLoadedPages = new Set(); + private longStripIntersectionObserver: IntersectionObserver | null = null; + private longStripScrollHandler: (() => void) | null = null; + private longStripScrollEndHandler: (() => void) | null = null; + private longStripScrollDebounceTimer: ReturnType | null = null; + private longStripScrollEndDebounceTimer: ReturnType | null = null; + private longStripIsScrolling = false; + private longStripAllImagesLoaded = false; + private longStripInitFinished = false; + private longStripPrevScrollTop = 0; + private preloadedImages = new Map(); previousImageUrls: string[] = []; currentImageUrls: string[] = []; @@ -115,16 +134,46 @@ export class CbxReaderComponent implements OnInit, OnDestroy { // Magnifier isMagnifierActive = false; - @ViewChild('magnifierLens', {static: true}) private magnifierLensRef!: ElementRef; + @ViewChild('magnifierLens', { static: true }) private magnifierLensRef!: ElementRef; magnifierZoom: CbxMagnifierZoom = CbxMagnifierZoom.ZOOM_3X; magnifierLensSize: CbxMagnifierLensSize = CbxMagnifierLensSize.MEDIUM; private lastMouseEvent: MouseEvent | null = null; - // Double page detection - private pageDimensionsCache = new Map(); + // Page split option for wide pages + pageSplitOption: CbxPageSplitOption = CbxPageSplitOption.NO_SPLIT; + + // Brightness filter (0-100, default 100) + brightness = 100; + + // Emulate book mode (spine shadow in two-page view) + emulateBook = false; + + // Click-to-paginate overlay + clickToPaginate = false; + + // Auto-close menu after interaction + autoCloseMenu = false; + private autoCloseMenuTimer: ReturnType | null = null; + private static readonly AUTO_CLOSE_MENU_TIMEOUT = 3000; + + // Page dimensions from backend + pageDimensions: CbxPageDimension[] = []; + doublePairs: DoublePairs = {}; + + // Double page detection (fallback cache for pages without backend dims) + private pageDimensionsCache = new Map(); + + // Swipe double-action prevention + private hasHitRightScroll = false; + private hasHitZeroScroll = false; + private touchMoveCount = 0; + + // Canvas split state + canvasSplitState: 'NO_SPLIT' | 'LEFT_PART' | 'RIGHT_PART' = 'NO_SPLIT'; protected readonly CbxReadingDirection = CbxReadingDirection; protected readonly CbxSlideshowInterval = CbxSlideshowInterval; + protected readonly CbxPageSplitOption = CbxPageSplitOption; private route = inject(ActivatedRoute); private router = inject(Router); @@ -139,6 +188,8 @@ export class CbxReaderComponent implements OnInit, OnDestroy { private sidebarService = inject(CbxSidebarService); private footerService = inject(CbxFooterService); private quickSettingsService = inject(CbxQuickSettingsService); + private pageDimensionService = inject(CbxPageDimensionService); + private wakeLockService = inject(WakeLockService); protected readonly CbxScrollMode = CbxScrollMode; protected readonly CbxFitMode = CbxFitMode; @@ -178,6 +229,9 @@ export class CbxReaderComponent implements OnInit, OnDestroy { this.subscribeToFooterEvents(); this.subscribeToQuickSettingsEvents(); + // Enable wake lock after a short delay to not block initial render + setTimeout(() => this.wakeLockService.enable(), 1000); + this.route.paramMap.pipe( takeUntil(this.destroy$), switchMap((params) => { @@ -215,78 +269,98 @@ export class CbxReaderComponent implements OnInit, OnDestroy { ); }) ).subscribe({ - next: ({ book, bookSettings, myself }) => { - const userSettings = myself.userSettings; - - this.pageTitle.setBookPageTitle(book); - - const title = book.metadata?.title || book.fileName; - this.headerService.initialize(title); - this.sidebarService.initialize(this.bookId, book, this.destroy$, this.altBookType); + next: ({ book, bookSettings, myself }) => { + const userSettings = myself.userSettings; - if (book.metadata?.seriesName) { - this.loadSeriesNavigation(book); - } - - const pagesObservable = this.cbxReaderService.getAvailablePages(this.bookId, this.altBookType); - - pagesObservable.subscribe({ - next: (pages) => { - this.pages = pages; - if (this.bookType === CbxReaderComponent.TYPE_CBX) { - const global = userSettings.perBookSetting.cbx === CbxReaderComponent.SETTING_GLOBAL; - this.pageViewMode = global - ? this.CbxPageViewMode[userSettings.cbxReaderSetting.pageViewMode as keyof typeof CbxPageViewMode] || this.CbxPageViewMode.SINGLE_PAGE - : this.CbxPageViewMode[bookSettings.cbxSettings?.pageViewMode as keyof typeof CbxPageViewMode] || this.CbxPageViewMode[userSettings.cbxReaderSetting.pageViewMode as keyof typeof CbxPageViewMode] || this.CbxPageViewMode.SINGLE_PAGE; - - this.pageSpread = global - ? this.CbxPageSpread[userSettings.cbxReaderSetting.pageSpread as keyof typeof CbxPageSpread] || this.CbxPageSpread.ODD - : this.CbxPageSpread[bookSettings.cbxSettings?.pageSpread as keyof typeof CbxPageSpread] || this.CbxPageSpread[userSettings.cbxReaderSetting.pageSpread as keyof typeof CbxPageSpread] || this.CbxPageSpread.ODD; + this.pageTitle.setBookPageTitle(book); - this.fitMode = global - ? this.CbxFitMode[userSettings.cbxReaderSetting.fitMode as keyof typeof CbxFitMode] || this.CbxFitMode.FIT_PAGE - : this.CbxFitMode[bookSettings.cbxSettings?.fitMode as keyof typeof CbxFitMode] || this.CbxFitMode[userSettings.cbxReaderSetting.fitMode as keyof typeof CbxFitMode] || this.CbxFitMode.FIT_PAGE; + const title = book.metadata?.title || book.fileName; + this.headerService.initialize(title); + this.sidebarService.initialize(this.bookId, book, this.destroy$, this.altBookType); - this.scrollMode = global - ? this.CbxScrollMode[userSettings.cbxReaderSetting.scrollMode as keyof typeof CbxScrollMode] || CbxScrollMode.PAGINATED - : this.CbxScrollMode[bookSettings.cbxSettings?.scrollMode as keyof typeof CbxScrollMode] || this.CbxScrollMode[userSettings.cbxReaderSetting.scrollMode as keyof typeof CbxScrollMode] || CbxScrollMode.PAGINATED; + if (book.metadata?.seriesName) { + this.loadSeriesNavigation(book); + } - this.backgroundColor = global - ? this.CbxBackgroundColor[userSettings.cbxReaderSetting.backgroundColor as keyof typeof CbxBackgroundColor] || CbxBackgroundColor.GRAY - : this.CbxBackgroundColor[bookSettings.cbxSettings?.backgroundColor as keyof typeof CbxBackgroundColor] || this.CbxBackgroundColor[userSettings.cbxReaderSetting.backgroundColor as keyof typeof CbxBackgroundColor] || CbxBackgroundColor.GRAY; + forkJoin([ + this.cbxReaderService.getAvailablePages(this.bookId, this.altBookType), + this.pageDimensionService.getPageDimensions(this.bookId, this.altBookType) + ]).subscribe({ + next: ([pages, dimensions]) => { + this.pages = pages; + this.pageDimensions = dimensions; + this.doublePairs = this.pageDimensionService.computeDoublePairs(dimensions); + + if (this.bookType === CbxReaderComponent.TYPE_CBX) { + const global = userSettings.perBookSetting.cbx === CbxReaderComponent.SETTING_GLOBAL; + this.pageViewMode = global + ? this.CbxPageViewMode[userSettings.cbxReaderSetting.pageViewMode as keyof typeof CbxPageViewMode] || this.CbxPageViewMode.SINGLE_PAGE + : this.CbxPageViewMode[bookSettings.cbxSettings?.pageViewMode as keyof typeof CbxPageViewMode] || this.CbxPageViewMode[userSettings.cbxReaderSetting.pageViewMode as keyof typeof CbxPageViewMode] || this.CbxPageViewMode.SINGLE_PAGE; + + this.pageSpread = global + ? this.CbxPageSpread[userSettings.cbxReaderSetting.pageSpread as keyof typeof CbxPageSpread] || this.CbxPageSpread.ODD + : this.CbxPageSpread[bookSettings.cbxSettings?.pageSpread as keyof typeof CbxPageSpread] || this.CbxPageSpread[userSettings.cbxReaderSetting.pageSpread as keyof typeof CbxPageSpread] || this.CbxPageSpread.ODD; + + this.fitMode = global + ? this.CbxFitMode[userSettings.cbxReaderSetting.fitMode as keyof typeof CbxFitMode] || this.CbxFitMode.FIT_PAGE + : this.CbxFitMode[bookSettings.cbxSettings?.fitMode as keyof typeof CbxFitMode] || this.CbxFitMode[userSettings.cbxReaderSetting.fitMode as keyof typeof CbxFitMode] || this.CbxFitMode.FIT_PAGE; + + this.scrollMode = global + ? this.CbxScrollMode[userSettings.cbxReaderSetting.scrollMode as keyof typeof CbxScrollMode] || CbxScrollMode.PAGINATED + : this.CbxScrollMode[bookSettings.cbxSettings?.scrollMode as keyof typeof CbxScrollMode] || this.CbxScrollMode[userSettings.cbxReaderSetting.scrollMode as keyof typeof CbxScrollMode] || CbxScrollMode.PAGINATED; + + this.backgroundColor = global + ? this.CbxBackgroundColor[userSettings.cbxReaderSetting.backgroundColor as keyof typeof CbxBackgroundColor] || CbxBackgroundColor.GRAY + : this.CbxBackgroundColor[bookSettings.cbxSettings?.backgroundColor as keyof typeof CbxBackgroundColor] || this.CbxBackgroundColor[userSettings.cbxReaderSetting.backgroundColor as keyof typeof CbxBackgroundColor] || CbxBackgroundColor.GRAY; + + // Restore new settings from per-book or global + const cbxSrc = global ? userSettings.cbxReaderSetting : bookSettings.cbxSettings; + if (cbxSrc) { + this.pageSplitOption = cbxSrc.pageSplitOption ?? this.pageSplitOption; + this.brightness = cbxSrc.brightness ?? 100; + this.emulateBook = cbxSrc.emulateBook ?? false; + this.clickToPaginate = cbxSrc.clickToPaginate ?? false; + this.autoCloseMenu = cbxSrc.autoCloseMenu ?? false; + } - this.currentPage = (book.cbxProgress?.page || 1) - 1; + this.currentPage = (book.cbxProgress?.page || 1) - 1; - if (this.scrollMode === CbxScrollMode.INFINITE) { - this.initializeInfiniteScroll(); - } + if (this.scrollMode as string === 'LONG_STRIP') { + this.scrollMode = CbxScrollMode.INFINITE; } - this.alignCurrentPageToParity(); - this.updateServiceStates(); - this.updateBookmarkState(); - this.updateNotesState(); - this.isLoading = false; - - this.updateCurrentImageUrls(); - this.preloadAdjacentPages(); - - const percentage = this.pages.length > 0 ? Math.round(((this.currentPage + 1) / this.pages.length) * 1000) / 10 : 0; - this.readingSessionService.startSession(this.bookId, "CBX", (this.currentPage + 1).toString(), percentage); - }, - error: (err) => { - const errorMessage = err?.error?.message || this.t.translate('shared.reader.failedToLoadPages'); - this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: errorMessage}); - this.isLoading = false; + if (this.scrollMode === CbxScrollMode.INFINITE) { + this.initializeInfiniteScroll(); + } else if (this.scrollMode === CbxScrollMode.LONG_STRIP) { + this.initializeLongStrip(); + } } - }); - }, - error: (err) => { - const errorMessage = err?.error?.message || this.t.translate('shared.reader.failedToLoadBook'); - this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: errorMessage}); - this.isLoading = false; - } - }); + + this.alignCurrentPageToParity(); + this.updateServiceStates(); + this.updateBookmarkState(); + this.updateNotesState(); + this.isLoading = false; + + this.updateCurrentImageUrls(); + this.preloadAdjacentPages(); + + const percentage = this.pages.length > 0 ? Math.round(((this.currentPage + 1) / this.pages.length) * 1000) / 10 : 0; + this.readingSessionService.startSession(this.bookId, "CBX", (this.currentPage + 1).toString(), percentage); + }, + error: (err) => { + const errorMessage = err?.error?.message || this.t.translate('shared.reader.failedToLoadPages'); + this.messageService.add({ severity: 'error', summary: this.t.translate('common.error'), detail: errorMessage }); + this.isLoading = false; + } + }); + }, + error: (err) => { + const errorMessage = err?.error?.message || this.t.translate('shared.reader.failedToLoadBook'); + this.messageService.add({ severity: 'error', summary: this.t.translate('common.error'), detail: errorMessage }); + this.isLoading = false; + } + }); } private subscribeToHeaderEvents(): void { @@ -327,7 +401,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (!this.isMagnifierActive) { this.hideMagnifier(); } - this.headerService.updateState({isMagnifierActive: this.isMagnifierActive}); + this.headerService.updateState({ isMagnifierActive: this.isMagnifierActive }); }); this.headerService.showShortcutsHelp$ @@ -421,6 +495,38 @@ export class CbxReaderComponent implements OnInit, OnDestroy { this.quickSettingsService.magnifierLensSizeChange$ .pipe(takeUntil(this.destroy$)) .subscribe(size => this.onMagnifierLensSizeChange(size)); + + this.quickSettingsService.brightnessChange$ + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.brightness = value; + this.quickSettingsService.setBrightness(value); + this.updateViewerSetting(); + }); + + this.quickSettingsService.emulateBookChange$ + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.emulateBook = value; + this.quickSettingsService.setEmulateBook(value); + this.updateViewerSetting(); + }); + + this.quickSettingsService.clickToPaginateChange$ + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.clickToPaginate = value; + this.quickSettingsService.setClickToPaginate(value); + this.updateViewerSetting(); + }); + + this.quickSettingsService.autoCloseMenuChange$ + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.autoCloseMenu = value; + this.quickSettingsService.setAutoCloseMenu(value); + this.updateViewerSetting(); + }); } private updateServiceStates(): void { @@ -442,7 +548,11 @@ export class CbxReaderComponent implements OnInit, OnDestroy { readingDirection: this.readingDirection, slideshowInterval: this.slideshowInterval, magnifierZoom: this.magnifierZoom, - magnifierLensSize: this.magnifierLensSize + magnifierLensSize: this.magnifierLensSize, + brightness: this.brightness, + emulateBook: this.emulateBook, + clickToPaginate: this.clickToPaginate, + autoCloseMenu: this.autoCloseMenu }); this.headerService.updateState({ @@ -469,8 +579,16 @@ export class CbxReaderComponent implements OnInit, OnDestroy { const urls: string[] = []; urls.push(this.getPageImageUrl(this.currentPage)); - if (this.isTwoPageView && this.currentPage + 1 < this.pages.length) { - urls.push(this.getPageImageUrl(this.currentPage + 1)); + if (this.isTwoPageView) { + // Use doublePairs for dimension-aware pairing + if (Object.keys(this.doublePairs).length > 0) { + const pairedWith = this.doublePairs[this.currentPage]; + if (pairedWith !== undefined && pairedWith < this.pages.length) { + urls.push(this.getPageImageUrl(pairedWith)); + } + } else if (this.currentPage + 1 < this.pages.length) { + urls.push(this.getPageImageUrl(this.currentPage + 1)); + } } this.currentImageUrls = urls; @@ -639,7 +757,19 @@ export class CbxReaderComponent implements OnInit, OnDestroy { const previousPage = this.currentPage; const step = this.getPageStep(); - if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) { + if (this.scrollMode === CbxScrollMode.LONG_STRIP) { + const newPage = this.currentPage + direction; + if (newPage >= 0 && newPage < this.pages.length) { + this.currentPage = newPage; + this.longStripScrollToPage(newPage); + this.updateProgress(); + this.updateSessionProgress(); + this.updateFooterPage(); + } + return; + } + + if (this.scrollMode === CbxScrollMode.INFINITE) { const newPage = this.currentPage + direction; if (newPage >= 0 && newPage < this.pages.length) { this.currentPage = newPage; @@ -654,21 +784,80 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (direction > 0) { // Forward navigation if (this.isTwoPageView) { - const effectiveStep = this.shouldShowSinglePage(this.currentPage) ? 1 : step; - if (this.currentPage + effectiveStep < this.pages.length) { - this.currentPage += effectiveStep; - } else if (this.currentPage + 1 < this.pages.length) { - this.currentPage += 1; + // Use doublePairs for dimension-aware stepping + if (Object.keys(this.doublePairs).length > 0) { + const pairedWith = this.doublePairs[this.currentPage]; + if (pairedWith !== undefined) { + // Current page is paired — skip past its partner + const nextPage = Math.max(this.currentPage, pairedWith) + 1; + if (nextPage < this.pages.length) { + this.currentPage = nextPage; + } + } else { + // Current page is solo (wide or cover) — advance by 1 + if (this.currentPage + 1 < this.pages.length) { + this.currentPage += 1; + } + } + } else { + // Fallback: use old heuristic + const effectiveStep = this.shouldShowSinglePage(this.currentPage) ? 1 : step; + if (this.currentPage + effectiveStep < this.pages.length) { + this.currentPage += effectiveStep; + } else if (this.currentPage + 1 < this.pages.length) { + this.currentPage += 1; + } } } else if (this.currentPage < this.pages.length - 1) { + // Single-page mode: handle canvas split state for wide pages + if (this.pageSplitOption !== CbxPageSplitOption.NO_SPLIT && this.isSpreadPage(this.currentPage)) { + if (this.canvasSplitState === 'NO_SPLIT' || this.canvasSplitState === 'LEFT_PART') { + // Show first half, then second half before advancing + this.canvasSplitState = this.canvasSplitState === 'NO_SPLIT' ? 'LEFT_PART' : 'RIGHT_PART'; + this.updateCurrentImageUrls(); + return; + } + // Already showed RIGHT_PART — advance to next page + this.canvasSplitState = 'NO_SPLIT'; + } this.currentPage++; } } else { // Backward navigation if (this.isTwoPageView) { - this.currentPage = Math.max(0, this.currentPage - step); + if (Object.keys(this.doublePairs).length > 0) { + // Find the page that starts the previous spread + const targetPage = this.currentPage - 1; + if (targetPage >= 0) { + const pairedWith = this.doublePairs[targetPage]; + if (pairedWith !== undefined) { + // Land on the lower-indexed page of the pair + this.currentPage = Math.min(targetPage, pairedWith); + } else { + // Previous page is solo — just go there + this.currentPage = targetPage; + } + } + } else { + this.currentPage = Math.max(0, this.currentPage - step); + } } else { - this.currentPage = Math.max(0, this.currentPage - 1); + // Single-page backward: handle canvas split for wide pages + if (this.pageSplitOption !== CbxPageSplitOption.NO_SPLIT && this.isSpreadPage(this.currentPage)) { + if (this.canvasSplitState === 'RIGHT_PART') { + this.canvasSplitState = 'LEFT_PART'; + this.updateCurrentImageUrls(); + return; + } + this.canvasSplitState = 'NO_SPLIT'; + } + if (this.currentPage > 0) { + this.currentPage--; + // If landing on a wide page, start at right half + if (this.pageSplitOption !== CbxPageSplitOption.NO_SPLIT && this.isSpreadPage(this.currentPage)) { + this.canvasSplitState = 'RIGHT_PART'; + } + } } } @@ -716,13 +905,22 @@ export class CbxReaderComponent implements OnInit, OnDestroy { } onScrollModeChange(mode: CbxScrollMode): void { + const previousMode = this.scrollMode; this.scrollMode = mode; this.quickSettingsService.setScrollMode(mode); this.updateViewerSetting(); - if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) { + // Teardown previous mode + if (previousMode === CbxScrollMode.LONG_STRIP) { + this.teardownLongStrip(); + } + + if (this.scrollMode === CbxScrollMode.INFINITE) { this.initializeInfiniteScroll(); - setTimeout(() => this.scrollToPage(this.currentPage), 100); + // Use instant scroll for mode switch to prevent teleportation feel + setTimeout(() => this.scrollToPage(this.currentPage, 'auto'), 50); + } else if (this.scrollMode === CbxScrollMode.LONG_STRIP) { + this.initializeLongStrip(); } else { this.updateCurrentImageUrls(); this.preloadAdjacentPages(); @@ -757,14 +955,15 @@ export class CbxReaderComponent implements OnInit, OnDestroy { private initializeInfiniteScroll(): void { this.infiniteScrollPages = []; - const endIndex = Math.min(this.currentPage + this.preloadCount, this.pages.length); - for (let i = this.currentPage; i < endIndex; i++) { + const startIndex = Math.max(0, this.currentPage - 2); + const endIndex = Math.min(this.currentPage + this.preloadCount + 1, this.pages.length); + for (let i = startIndex; i < endIndex; i++) { this.infiniteScrollPages.push(i); } } onScroll(event: Event): void { - if ((this.scrollMode !== CbxScrollMode.INFINITE && this.scrollMode !== CbxScrollMode.LONG_STRIP) || this.isLoadingMore) return; + if (this.scrollMode !== CbxScrollMode.INFINITE || this.isLoadingMore) return; const container = event.target as HTMLElement; const scrollPosition = container.scrollTop + container.clientHeight; @@ -820,6 +1019,249 @@ export class CbxReaderComponent implements OnInit, OnDestroy { return this.cbxReaderService.getPageImageUrl(this.bookId, this.pages[pageIndex], this.altBookType); } + // ── Long-strip mode (Kavita-inspired) ── + + private initializeLongStrip(): void { + this.longStripImages = []; + this.longStripLoadedPages.clear(); + this.longStripAllImagesLoaded = false; + this.longStripInitFinished = false; + this.longStripIsScrolling = false; + this.longStripPrevScrollTop = 0; + + // Prefetch pages around current position + this.longStripPrefetchAround(this.currentPage); + + // Set up IntersectionObserver for page visibility tracking + this.longStripIntersectionObserver = new IntersectionObserver( + (entries) => this.longStripHandleIntersection(entries), + { threshold: 0.01 } + ); + + // Attach scroll listeners after a tick so the DOM is ready + setTimeout(() => { + this.longStripAttachScrollListeners(); + this.longStripScrollToPage(this.currentPage); + }, 50); + } + + private teardownLongStrip(): void { + if (this.longStripIntersectionObserver) { + this.longStripIntersectionObserver.disconnect(); + this.longStripIntersectionObserver = null; + } + this.longStripDetachScrollListeners(); + this.longStripImages = []; + this.longStripLoadedPages.clear(); + this.longStripAllImagesLoaded = false; + this.longStripInitFinished = false; + } + + private longStripPrefetchAround(pageNum: number): void { + const buffer = CbxReaderComponent.LONG_STRIP_BUFFER; + const start = Math.max(0, pageNum - buffer); + const end = Math.min(this.pages.length - 1, pageNum + buffer); + + for (let i = start; i <= end; i++) { + if (!this.longStripLoadedPages.has(i)) { + this.longStripLoadedPages.add(i); + this.longStripImages.push({ src: this.getPageImageUrl(i), page: i }); + } + } + + // Keep sorted by page number + this.longStripImages.sort((a, b) => a.page - b.page); + } + + onLongStripImageLoad(event: Event, pageIndex: number): void { + const img = event.target as HTMLImageElement; + if (!img.naturalWidth || !img.naturalHeight) return; + + // Cache dimensions + this.pageDimensionsCache.set(pageIndex, { + width: img.naturalWidth, + height: img.naturalHeight + }); + + // Attach intersection observer to track visibility + if (this.longStripIntersectionObserver) { + this.longStripIntersectionObserver.observe(img); + } + + // Check if this is the current page's image — if so, scroll to it on first load + if (pageIndex === this.currentPage && !this.longStripInitFinished) { + this.longStripWaitForImagesAndScroll(); + } + } + + private longStripWaitForImagesAndScroll(): void { + // Wait for all currently-in-DOM images to load, then scroll + const container = document.querySelector('.image-container.long-strip'); + if (!container) return; + + const imgs = Array.from(container.querySelectorAll('img[data-page]')) as HTMLImageElement[]; + const pending = imgs.filter(img => !img.complete); + + if (pending.length === 0) { + this.longStripAllImagesLoaded = true; + this.longStripDoScroll(this.currentPage); + this.longStripInitFinished = true; + return; + } + + Promise.all(pending.map(img => new Promise(resolve => { + img.onload = () => resolve(); + img.onerror = () => resolve(); + }))).then(() => { + this.longStripAllImagesLoaded = true; + this.longStripDoScroll(this.currentPage); + this.longStripInitFinished = true; + }); + } + + private longStripDoScroll(pageNum: number): void { + const el = document.querySelector(`img[data-page="${pageNum}"]`); + if (!el) return; + + this.longStripIsScrolling = true; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Clear the scrolling flag once the target is visible + setTimeout(() => { + this.longStripIsScrolling = false; + }, 600); + } + + private longStripScrollToPage(pageNum: number): void { + this.longStripPrefetchAround(pageNum); + + // Wait a tick for new images to render in DOM, then scroll + setTimeout(() => this.longStripDoScroll(pageNum), 50); + } + + private longStripHandleIntersection(entries: IntersectionObserverEntry[]): void { + if (!this.longStripAllImagesLoaded || this.longStripIsScrolling) return; + + for (const entry of entries) { + if (entry.isIntersecting) { + const pageAttr = entry.target.getAttribute('data-page'); + if (pageAttr != null) { + const page = parseInt(pageAttr, 10); + // Prefetch more images when a page enters the viewport + this.longStripPrefetchAround(page); + } + } + } + } + + private longStripAttachScrollListeners(): void { + const container = document.querySelector('.image-container.long-strip') as HTMLElement; + if (!container) return; + + this.longStripScrollHandler = () => { + if (this.longStripScrollDebounceTimer) clearTimeout(this.longStripScrollDebounceTimer); + this.longStripScrollDebounceTimer = setTimeout(() => { + this.longStripOnScroll(container); + }, 20); + }; + + const supportsScrollEnd = 'onscrollend' in document; + this.longStripScrollEndHandler = () => { + if (this.longStripScrollEndDebounceTimer) clearTimeout(this.longStripScrollEndDebounceTimer); + this.longStripScrollEndDebounceTimer = setTimeout(() => { + this.longStripOnScrollEnd(container); + }, supportsScrollEnd ? 20 : 100); + }; + + container.addEventListener('scroll', this.longStripScrollHandler, { passive: true }); + container.addEventListener( + supportsScrollEnd ? 'scrollend' : 'scroll', + this.longStripScrollEndHandler, + { passive: true } + ); + } + + private longStripDetachScrollListeners(): void { + const container = document.querySelector('.image-container.long-strip') as HTMLElement; + if (!container) return; + + if (this.longStripScrollHandler) { + container.removeEventListener('scroll', this.longStripScrollHandler); + this.longStripScrollHandler = null; + } + if (this.longStripScrollEndHandler) { + container.removeEventListener('scrollend', this.longStripScrollEndHandler); + container.removeEventListener('scroll', this.longStripScrollEndHandler); + this.longStripScrollEndHandler = null; + } + if (this.longStripScrollDebounceTimer) { + clearTimeout(this.longStripScrollDebounceTimer); + this.longStripScrollDebounceTimer = null; + } + if (this.longStripScrollEndDebounceTimer) { + clearTimeout(this.longStripScrollEndDebounceTimer); + this.longStripScrollEndDebounceTimer = null; + } + } + + private longStripOnScroll(container: HTMLElement): void { + const scrollTop = container.scrollTop; + + // Track direction + if (scrollTop > this.longStripPrevScrollTop) { + // scrolling down + } + this.longStripPrevScrollTop = scrollTop; + + // If performing a programmatic scroll, check if target is visible + if (this.longStripIsScrolling) { + const target = document.querySelector(`img[data-page="${this.currentPage}"]`); + if (target && this.longStripIsElementVisible(target, container)) { + this.longStripIsScrolling = false; + } + } + } + + private longStripOnScrollEnd(container: HTMLElement): void { + if (this.longStripIsScrolling) return; + + // Find the image closest to the top of the viewport + const images = Array.from(container.querySelectorAll('img[data-page]')) as HTMLImageElement[]; + const closest = this.longStripFindClosestImage(images); + + if (closest) { + const page = parseInt(closest.getAttribute('data-page') ?? '0', 10); + if (page !== this.currentPage) { + this.currentPage = page; + this.progressSaveSubject$.next(); + this.updateSessionProgress(); + this.updateFooterPage(); + } + } + } + + private longStripFindClosestImage(images: HTMLImageElement[]): HTMLImageElement | null { + let closest: HTMLImageElement | null = null; + let closestDist = Number.MAX_VALUE; + + for (const img of images) { + const rect = img.getBoundingClientRect(); + const dist = Math.abs(rect.top); + if (dist < closestDist) { + closestDist = dist; + closest = img; + } + } + + return closest; + } + + private longStripIsElementVisible(elem: Element, container: HTMLElement): boolean { + const rect = elem.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + return rect.bottom >= containerRect.top && rect.top <= containerRect.bottom; + } + private updateViewerSetting(): void { const bookSetting: BookSetting = { cbxSettings: { @@ -828,6 +1270,11 @@ export class CbxReaderComponent implements OnInit, OnDestroy { fitMode: this.fitMode, scrollMode: this.scrollMode, backgroundColor: this.backgroundColor, + pageSplitOption: this.pageSplitOption, + brightness: this.brightness, + emulateBook: this.emulateBook, + clickToPaginate: this.clickToPaginate, + autoCloseMenu: this.autoCloseMenu, } }; this.bookService.updateViewerSetting(bookSetting, this.bookId).subscribe(); @@ -859,7 +1306,13 @@ export class CbxReaderComponent implements OnInit, OnDestroy { this.currentPage = targetIndex; - if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) { + if (this.scrollMode === CbxScrollMode.LONG_STRIP) { + this.longStripPrefetchAround(targetIndex); + this.longStripScrollToPage(targetIndex); + this.updateProgress(); + this.updateSessionProgress(); + this.updateFooterPage(); + } else if (this.scrollMode === CbxScrollMode.INFINITE) { this.ensurePageLoaded(targetIndex); this.scrollToPage(targetIndex); this.updateProgress(); @@ -882,11 +1335,11 @@ export class CbxReaderComponent implements OnInit, OnDestroy { this.goToPage(this.pages.length); } - private scrollToPage(pageIndex: number): void { + private scrollToPage(pageIndex: number, behavior: ScrollBehavior = 'smooth'): void { this.ensurePageLoaded(pageIndex); setTimeout(() => { - const container = document.querySelector('.image-container.infinite-scroll, .image-container.long-strip') as HTMLElement; + const container = document.querySelector('.image-container.infinite-scroll') as HTMLElement; if (!container) return; const images = container.querySelectorAll('.page-image'); @@ -894,17 +1347,21 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (indexInScroll >= 0 && indexInScroll < images.length) { const targetImage = images[indexInScroll] as HTMLElement; - targetImage.scrollIntoView({behavior: 'smooth', block: 'start'}); + targetImage.scrollIntoView({ behavior: behavior, block: 'start' }); + } else if (behavior === 'auto') { + // Fallback for instant scroll: just go to top + container.scrollTop = 0; } - }, 100); + }, 50); } private ensurePageLoaded(pageIndex: number): void { if (this.infiniteScrollPages.includes(pageIndex)) return; this.infiniteScrollPages = []; + // Load a window around the target page const startIndex = Math.max(0, pageIndex - 1); - const endIndex = Math.min(pageIndex + this.preloadCount, this.pages.length); + const endIndex = Math.min(pageIndex + this.preloadCount + 1, this.pages.length); for (let i = startIndex; i < endIndex; i++) { this.infiniteScrollPages.push(i); @@ -913,6 +1370,18 @@ export class CbxReaderComponent implements OnInit, OnDestroy { onImageClick(): void { this.visibilityManager.togglePinned(); + this.scheduleAutoCloseMenu(); + } + + private scheduleAutoCloseMenu(): void { + if (!this.autoCloseMenu) return; + if (this.autoCloseMenuTimer) { + clearTimeout(this.autoCloseMenuTimer); + } + this.autoCloseMenuTimer = setTimeout(() => { + this.visibilityManager.unpinIfPinned(); + this.autoCloseMenuTimer = null; + }, CbxReaderComponent.AUTO_CLOSE_MENU_TIMEOUT); } @HostListener('window:keydown', ['$event']) @@ -988,7 +1457,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (!this.isMagnifierActive) { this.hideMagnifier(); } - this.headerService.updateState({isMagnifierActive: this.isMagnifierActive}); + this.headerService.updateState({ isMagnifierActive: this.isMagnifierActive }); break; case '+': case '=': @@ -1023,7 +1492,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (this.isMagnifierActive) { this.isMagnifierActive = false; this.hideMagnifier(); - this.headerService.updateState({isMagnifierActive: false}); + this.headerService.updateState({ isMagnifierActive: false }); } else if (this.showShortcutsHelp) { this.showShortcutsHelp = false; } else if (this.showNoteDialog) { @@ -1040,18 +1509,27 @@ export class CbxReaderComponent implements OnInit, OnDestroy { @HostListener('document:fullscreenchange') onFullscreenChange(): void { this.isFullscreen = !!document.fullscreenElement; - this.headerService.updateState({isFullscreen: this.isFullscreen, isSlideshowActive: this.isSlideshowActive}); + this.headerService.updateState({ isFullscreen: this.isFullscreen, isSlideshowActive: this.isSlideshowActive }); } @HostListener('touchstart', ['$event']) onTouchStart(event: TouchEvent) { this.touchStartX = event.changedTouches[0].screenX; + this.touchMoveCount = 0; + } + + @HostListener('touchmove') + onTouchMove() { + this.touchMoveCount++; } @HostListener('touchend', ['$event']) onTouchEnd(event: TouchEvent) { this.touchEndX = event.changedTouches[0].screenX; - this.handleSwipeGesture(); + // Filter tremor/jitter: ignore if fewer than 3 move events + if (this.touchMoveCount >= 3) { + this.handleSwipeGesture(); + } } @HostListener('window:resize') @@ -1081,16 +1559,36 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) return; const delta = this.touchEndX - this.touchStartX; - if (Math.abs(delta) >= 50) { - // In RTL mode, swipe directions are reversed - const isRtl = this.readingDirection === CbxReadingDirection.RTL; - const shouldGoNext = isRtl ? delta > 0 : delta < 0; - if (shouldGoNext) { - this.nextPage(); - } else { - this.previousPage(); + const threshold = Math.min(75, window.innerWidth * 0.1); + if (Math.abs(delta) < threshold) return; + + const isRtl = this.readingDirection === CbxReadingDirection.RTL; + const shouldGoNext = isRtl ? delta > 0 : delta < 0; + const shouldGoPrev = !shouldGoNext; + + // Double-action prevention: at boundaries, require two consecutive swipes + if (shouldGoNext && this.currentPage >= this.pages.length - 1) { + if (!this.hasHitRightScroll) { + this.hasHitRightScroll = true; + return; } } + if (shouldGoPrev && this.currentPage <= 0) { + if (!this.hasHitZeroScroll) { + this.hasHitZeroScroll = true; + return; + } + } + + // Reset boundary flags on successful navigation + this.hasHitRightScroll = false; + this.hasHitZeroScroll = false; + + if (shouldGoNext) { + this.nextPage(); + } else { + this.previousPage(); + } } private enforcePortraitSinglePageView() { @@ -1113,14 +1611,14 @@ export class CbxReaderComponent implements OnInit, OnDestroy { navigateToPreviousBook(): void { if (this.previousBookInSeries) { this.endReadingSession(); - this.router.navigate(['/cbx-reader/book', this.previousBookInSeries.id], {replaceUrl: true}); + this.router.navigate(['/cbx-reader/book', this.previousBookInSeries.id], { replaceUrl: true }); } } navigateToNextBook(): void { if (this.nextBookInSeries) { this.endReadingSession(); - this.router.navigate(['/cbx-reader/book', this.nextBookInSeries.id], {replaceUrl: true}); + this.router.navigate(['/cbx-reader/book', this.nextBookInSeries.id], { replaceUrl: true }); } } @@ -1222,7 +1720,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy { if (this.currentPage >= this.pages.length - 1) return; this.isSlideshowActive = true; - this.headerService.updateState({isFullscreen: this.isFullscreen, isSlideshowActive: true}); + this.headerService.updateState({ isFullscreen: this.isFullscreen, isSlideshowActive: true }); this.slideshowTimer = setInterval(() => { if (this.currentPage < this.pages.length - 1) { @@ -1239,7 +1737,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy { this.slideshowTimer = null; } this.isSlideshowActive = false; - this.headerService.updateState({isFullscreen: this.isFullscreen, isSlideshowActive: false}); + this.headerService.updateState({ isFullscreen: this.isFullscreen, isSlideshowActive: false }); } private pauseSlideshowOnInteraction(): void { @@ -1323,6 +1821,11 @@ export class CbxReaderComponent implements OnInit, OnDestroy { } isSpreadPage(pageIndex: number): boolean { + // Prefer backend-provided dimensions + if (this.pageDimensions.length > pageIndex) { + return this.pageDimensions[pageIndex].wide; + } + // Fallback to client-side cache const dims = this.pageDimensionsCache.get(pageIndex); if (!dims) return false; return dims.width > dims.height * 1.5; @@ -1332,6 +1835,41 @@ export class CbxReaderComponent implements OnInit, OnDestroy { return this.isTwoPageView && this.isSpreadPage(pageIndex); } + shouldUseCanvasRenderer(): boolean { + if (this.pageSplitOption === CbxPageSplitOption.NO_SPLIT) return false; + if (!this.pageViewMode || this.isTwoPageView) return false; + return this.isSpreadPage(this.currentPage) && this.canvasSplitState !== 'NO_SPLIT'; + } + + onClickZonePrev(event: Event): void { + event.stopPropagation(); + if (this.scrollMode === CbxScrollMode.INFINITE) { + const container = document.querySelector('.image-container.infinite-scroll'); + if (container) { + container.scrollBy({ top: -container.clientHeight * 0.9, behavior: 'smooth' }); + } + } else { + this.previousPage(); + } + } + + onClickZoneNext(event: Event): void { + event.stopPropagation(); + if (this.scrollMode === CbxScrollMode.INFINITE) { + const container = document.querySelector('.image-container.infinite-scroll'); + if (container) { + container.scrollBy({ top: container.clientHeight * 0.9, behavior: 'smooth' }); + } + } else { + this.nextPage(); + } + } + + onClickZoneMenu(event: Event): void { + event.stopPropagation(); + this.onImageClick(); + } + private updateMagnifier(event: MouseEvent): void { const el = this.magnifierLensRef?.nativeElement; if (!el) return; @@ -1391,14 +1929,16 @@ export class CbxReaderComponent implements OnInit, OnDestroy { this.showShortcutsHelp = false; } - // Long strip mode check - get isLongStripMode(): boolean { - return this.scrollMode === CbxScrollMode.LONG_STRIP; - } + ngOnDestroy(): void { this.stopSlideshow(); + this.teardownLongStrip(); this.endReadingSession(); + this.wakeLockService.disable(); + if (this.autoCloseMenuTimer) { + clearTimeout(this.autoCloseMenuTimer); + } this.destroy$.next(); this.destroy$.complete(); } diff --git a/frontend/src/app/features/readers/cbx-reader/core/cbx-page-dimension.service.ts b/frontend/src/app/features/readers/cbx-reader/core/cbx-page-dimension.service.ts new file mode 100644 index 000000000..dbfdc6aba --- /dev/null +++ b/frontend/src/app/features/readers/cbx-reader/core/cbx-page-dimension.service.ts @@ -0,0 +1,188 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable, inject} from '@angular/core'; +import {Observable, of, tap} from 'rxjs'; +import {API_CONFIG} from '../../../../core/config/api-config'; +import {AuthService} from '../../../../shared/service/auth.service'; +import {CbxPageDimension} from '../models/cbx-page-dimension.model'; + +export type DoublePairs = Record; + +export interface WebtoonDetectionResult { + isWebtoon: boolean; + score: number; +} + +@Injectable({providedIn: 'root'}) +export class CbxPageDimensionService { + + private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/cbx`; + private http = inject(HttpClient); + private authService = inject(AuthService); + + private dimensionCache = new Map(); + + private getToken(): string | null { + return this.authService.getInternalAccessToken(); + } + + private appendToken(url: string): string { + const token = this.getToken(); + return token ? `${url}${url.includes('?') ? '&' : '?'}token=${token}` : url; + } + + getPageDimensions(bookId: number, bookType?: string): Observable { + const cacheKey = `${bookId}-${bookType ?? 'default'}`; + const cached = this.dimensionCache.get(cacheKey); + if (cached) { + return of(cached); + } + let url = `${this.baseUrl}/${bookId}/page-dimensions`; + if (bookType) { + url += `?bookType=${bookType}`; + } + return this.http.get(this.appendToken(url)).pipe( + tap(dims => this.dimensionCache.set(cacheKey, dims)) + ); + } + + clearCache(bookId?: number): void { + if (bookId !== undefined) { + for (const key of this.dimensionCache.keys()) { + if (key.startsWith(`${bookId}-`)) { + this.dimensionCache.delete(key); + } + } + } else { + this.dimensionCache.clear(); + } + } + + /** + * Compute double-page pair map from dimensions. + * Cover (page index 0) is always solo. Wide pages are always solo. + * Returns a map of pageIndex → paired pageIndex for the right-hand page. + */ + computeDoublePairs(dimensions: CbxPageDimension[]): DoublePairs { + const pairs: DoublePairs = {}; + let i = 1; // Skip cover (index 0), it's always solo + while (i < dimensions.length) { + const current = dimensions[i]; + const next = i + 1 < dimensions.length ? dimensions[i + 1] : null; + + if (current.wide) { + // Wide page is always solo + i++; + continue; + } + + if (next && !next.wide) { + // Pair these two non-wide pages + pairs[i] = i + 1; + i += 2; + } else { + // Next is wide or doesn't exist — current is solo + i++; + } + } + return pairs; + } + + /** + * Detect if the content is webtoon-style (long vertical strips). + * + * Scoring per page: + * - Aspect ratio (h/w) ≥ 2.2: +1.0 (strong webtoon indicator) + * - Aspect ratio 1.8–2.2: +0.5 + * - Aspect ratio 1.5–1.8: +0.2 + * - Aspect ratio < 1.2: −0.5 (penalize square/landscape) + * - Width ≤ 750px: +0.2 + * - Height > 2000px: +0.5 (or > 1500px: +0.3) + * - Area > 1,500,000 px²: +0.3 + * + * Final decision: avgScore ≥ 0.7 AND one of: + * - 40%+ pages have ratio ≥ 2.2 + * - Average ratio ≥ 2.0 + * - Width variation < 15% AND average ratio > 1.8 + */ + detectWebtoon(dimensions: CbxPageDimension[]): WebtoonDetectionResult { + if (dimensions.length < 3) { + return {isWebtoon: false, score: 0}; + } + + let totalScore = 0; + let highRatioCount = 0; + let totalRatio = 0; + const widths: number[] = []; + + for (const dim of dimensions) { + if (dim.width === 0 || dim.height === 0) continue; + + const ratio = dim.height / dim.width; + totalRatio += ratio; + widths.push(dim.width); + + let pageScore = 0; + + // Aspect ratio scoring + if (ratio >= 2.2) { + pageScore += 1.0; + highRatioCount++; + } else if (ratio >= 1.8) { + pageScore += 0.5; + } else if (ratio >= 1.5) { + pageScore += 0.2; + } else if (ratio < 1.2) { + pageScore -= 0.5; + } + + // Narrow width bonus + if (dim.width <= 750) { + pageScore += 0.2; + } + + // Tall page bonus + if (dim.height > 2000) { + pageScore += 0.5; + } else if (dim.height > 1500) { + pageScore += 0.3; + } + + // Large area bonus + if (dim.width * dim.height > 1_500_000) { + pageScore += 0.3; + } + + totalScore += pageScore; + } + + const validCount = widths.length; + if (validCount < 3) { + return {isWebtoon: false, score: 0}; + } + + const avgScore = totalScore / validCount; + const avgRatio = totalRatio / validCount; + const avgWidth = widths.reduce((a, b) => a + b, 0) / validCount; + const widthStdDev = Math.sqrt( + widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) / validCount + ); + const widthVariation = avgWidth > 0 ? widthStdDev / avgWidth : 1; + + // Reject traditional comics with small, square-ish pages + const avgHeight = dimensions + .filter(d => d.height > 0) + .reduce((sum, d) => sum + d.height, 0) / validCount; + if (avgHeight < 1200 && avgRatio < 1.7 && avgWidth < 700) { + return {isWebtoon: false, score: avgScore}; + } + + const highRatioPercent = highRatioCount / validCount; + const isWebtoon = avgScore >= 0.7 && ( + highRatioPercent >= 0.4 || + avgRatio >= 2.0 || + (widthVariation < 0.15 && avgRatio > 1.8) + ); + + return {isWebtoon, score: avgScore}; + } +} diff --git a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.html b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.html index e92ba87db..4a75e2332 100644 --- a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.html +++ b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.html @@ -1,5 +1,7 @@ -
-
+
+
@@ -8,13 +10,10 @@
@for (option of fitModeOptions; track option.value) { - + }
@@ -26,13 +25,10 @@
@for (option of scrollModeOptions; track option.value) { - + }
@@ -40,36 +36,42 @@ @if (isPaginated && !isPhonePortrait) { -
- {{ 'readerCbx.quickSettings.pageView' | transloco }} -
- {{ isTwoPageView ? ('readerCbx.quickSettings.twoPage' | transloco) : ('readerCbx.quickSettings.single' | transloco) }} - -
+
+ {{ 'readerCbx.quickSettings.pageView' | transloco }} +
+ {{ isTwoPageView ? ('readerCbx.quickSettings.twoPage' | transloco) : + ('readerCbx.quickSettings.single' | transloco) }} +
+
} @if (isPaginated && isTwoPageView) { -
- {{ 'readerCbx.quickSettings.pageSpread' | transloco }} -
- {{ state().pageSpread === CbxPageSpread.ODD ? ('readerCbx.quickSettings.oddFirst' | transloco) : ('readerCbx.quickSettings.evenFirst' | transloco) }} - -
+
+ {{ 'readerCbx.quickSettings.pageSpread' | transloco }} +
+ {{ state().pageSpread === CbxPageSpread.ODD ? ('readerCbx.quickSettings.oddFirst' | + transloco) : ('readerCbx.quickSettings.evenFirst' | transloco) }} +
+
}
{{ 'readerCbx.quickSettings.readingDirection' | transloco }}
- {{ state().readingDirection === CbxReadingDirection.LTR ? ('readerCbx.quickSettings.leftToRight' | transloco) : ('readerCbx.quickSettings.rightToLeft' | transloco) }} -
@@ -81,13 +83,10 @@
@for (option of slideshowIntervalOptions; track option.value) { - + }
@@ -99,13 +98,10 @@
@for (option of backgroundOptions; track option.value) { - + }
@@ -117,13 +113,10 @@
@for (option of magnifierLensSizeOptions; track option.value) { - + }
@@ -135,17 +128,60 @@
@for (option of magnifierZoomOptions; track option.value) { - + }
+ + +
+ {{ 'readerCbx.quickSettings.brightness' | transloco }} +
+ + {{ state().brightness }}% +
+
+ + +
+ {{ 'readerCbx.quickSettings.emulateBook' | transloco }} +
+ {{ state().emulateBook ? ('common.on' | transloco) : ('common.off' | transloco) + }} + +
+
+ + +
+ {{ 'readerCbx.quickSettings.clickToPaginate' | transloco }} +
+ {{ state().clickToPaginate ? ('common.on' | transloco) : ('common.off' | transloco) + }} + +
+
+ + +
+ {{ 'readerCbx.quickSettings.autoCloseMenu' | transloco }} +
+ {{ state().autoCloseMenu ? ('common.on' | transloco) : ('common.off' | transloco) + }} + +
+
-
+
\ No newline at end of file diff --git a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts index 99f7abaa8..0843b2501 100644 --- a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts +++ b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts @@ -1,6 +1,6 @@ -import {Component, inject} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {TranslocoService, TranslocoPipe} from '@jsverse/transloco'; +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslocoService, TranslocoPipe } from '@jsverse/transloco'; import { CbxFitMode, CbxScrollMode, @@ -12,8 +12,8 @@ import { CbxMagnifierZoom, CbxMagnifierLensSize } from '../../../../settings/user-management/user.service'; -import {ReaderIconComponent, ReaderIconName} from '../../../ebook-reader/shared/icon.component'; -import {CbxQuickSettingsService} from './cbx-quick-settings.service'; +import { ReaderIconComponent, ReaderIconName } from '../../../ebook-reader/shared/icon.component'; +import { CbxQuickSettingsService } from './cbx-quick-settings.service'; @Component({ selector: 'app-cbx-quick-settings', @@ -38,52 +38,52 @@ export class CbxQuickSettingsComponent { protected readonly CbxMagnifierZoom = CbxMagnifierZoom; protected readonly CbxMagnifierLensSize = CbxMagnifierLensSize; - get fitModeOptions(): {value: CbxFitMode, label: string, icon: ReaderIconName}[] { + get fitModeOptions(): { value: CbxFitMode, label: string, icon: ReaderIconName }[] { return [ - {value: CbxFitMode.FIT_PAGE, label: this.t.translate('readerCbx.quickSettings.fitPage'), icon: 'fit-page'}, - {value: CbxFitMode.FIT_WIDTH, label: this.t.translate('readerCbx.quickSettings.fitWidth'), icon: 'fit-width'}, - {value: CbxFitMode.FIT_HEIGHT, label: this.t.translate('readerCbx.quickSettings.fitHeight'), icon: 'fit-height'}, - {value: CbxFitMode.ACTUAL_SIZE, label: this.t.translate('readerCbx.quickSettings.actualSize'), icon: 'actual-size'}, - {value: CbxFitMode.AUTO, label: this.t.translate('readerCbx.quickSettings.automatic'), icon: 'auto-fit'} + { value: CbxFitMode.FIT_PAGE, label: this.t.translate('readerCbx.quickSettings.fitPage'), icon: 'fit-page' }, + { value: CbxFitMode.FIT_WIDTH, label: this.t.translate('readerCbx.quickSettings.fitWidth'), icon: 'fit-width' }, + { value: CbxFitMode.FIT_HEIGHT, label: this.t.translate('readerCbx.quickSettings.fitHeight'), icon: 'fit-height' }, + { value: CbxFitMode.ACTUAL_SIZE, label: this.t.translate('readerCbx.quickSettings.actualSize'), icon: 'actual-size' }, + { value: CbxFitMode.AUTO, label: this.t.translate('readerCbx.quickSettings.automatic'), icon: 'auto-fit' } ]; } - get scrollModeOptions(): {value: CbxScrollMode, label: string}[] { + get scrollModeOptions(): { value: CbxScrollMode, label: string }[] { return [ - {value: CbxScrollMode.PAGINATED, label: this.t.translate('readerCbx.quickSettings.paginated')}, - {value: CbxScrollMode.INFINITE, label: this.t.translate('readerCbx.quickSettings.infinite')}, - {value: CbxScrollMode.LONG_STRIP, label: this.t.translate('readerCbx.quickSettings.longStrip')} + { value: CbxScrollMode.PAGINATED, label: this.t.translate('readerCbx.quickSettings.paginated') }, + { value: CbxScrollMode.INFINITE, label: this.t.translate('readerCbx.quickSettings.infinite') } + // { value: CbxScrollMode.LONG_STRIP, label: this.t.translate('readerCbx.quickSettings.longStrip') } ]; } - slideshowIntervalOptions: {value: CbxSlideshowInterval, label: string}[] = [ - {value: CbxSlideshowInterval.THREE_SECONDS, label: '3s'}, - {value: CbxSlideshowInterval.FIVE_SECONDS, label: '5s'}, - {value: CbxSlideshowInterval.TEN_SECONDS, label: '10s'}, - {value: CbxSlideshowInterval.FIFTEEN_SECONDS, label: '15s'}, - {value: CbxSlideshowInterval.THIRTY_SECONDS, label: '30s'} + slideshowIntervalOptions: { value: CbxSlideshowInterval, label: string }[] = [ + { value: CbxSlideshowInterval.THREE_SECONDS, label: '3s' }, + { value: CbxSlideshowInterval.FIVE_SECONDS, label: '5s' }, + { value: CbxSlideshowInterval.TEN_SECONDS, label: '10s' }, + { value: CbxSlideshowInterval.FIFTEEN_SECONDS, label: '15s' }, + { value: CbxSlideshowInterval.THIRTY_SECONDS, label: '30s' } ]; - magnifierZoomOptions: {value: CbxMagnifierZoom, label: string}[] = [ - {value: CbxMagnifierZoom.ZOOM_1_5X, label: '1.5×'}, - {value: CbxMagnifierZoom.ZOOM_2X, label: '2×'}, - {value: CbxMagnifierZoom.ZOOM_2_5X, label: '2.5×'}, - {value: CbxMagnifierZoom.ZOOM_3X, label: '3×'}, - {value: CbxMagnifierZoom.ZOOM_4X, label: '4×'} + magnifierZoomOptions: { value: CbxMagnifierZoom, label: string }[] = [ + { value: CbxMagnifierZoom.ZOOM_1_5X, label: '1.5×' }, + { value: CbxMagnifierZoom.ZOOM_2X, label: '2×' }, + { value: CbxMagnifierZoom.ZOOM_2_5X, label: '2.5×' }, + { value: CbxMagnifierZoom.ZOOM_3X, label: '3×' }, + { value: CbxMagnifierZoom.ZOOM_4X, label: '4×' } ]; - magnifierLensSizeOptions: {value: CbxMagnifierLensSize, label: string}[] = [ - {value: CbxMagnifierLensSize.SMALL, label: 'S'}, - {value: CbxMagnifierLensSize.MEDIUM, label: 'M'}, - {value: CbxMagnifierLensSize.LARGE, label: 'L'}, - {value: CbxMagnifierLensSize.EXTRA_LARGE, label: 'XL'} + magnifierLensSizeOptions: { value: CbxMagnifierLensSize, label: string }[] = [ + { value: CbxMagnifierLensSize.SMALL, label: 'S' }, + { value: CbxMagnifierLensSize.MEDIUM, label: 'M' }, + { value: CbxMagnifierLensSize.LARGE, label: 'L' }, + { value: CbxMagnifierLensSize.EXTRA_LARGE, label: 'XL' } ]; get backgroundOptions() { return [ - {value: CbxBackgroundColor.BLACK, label: this.t.translate('readerCbx.quickSettings.black'), color: '#000000'}, - {value: CbxBackgroundColor.GRAY, label: this.t.translate('readerCbx.quickSettings.gray'), color: '#808080'}, - {value: CbxBackgroundColor.WHITE, label: this.t.translate('readerCbx.quickSettings.white'), color: '#ffffff'} + { value: CbxBackgroundColor.BLACK, label: this.t.translate('readerCbx.quickSettings.black'), color: '#000000' }, + { value: CbxBackgroundColor.GRAY, label: this.t.translate('readerCbx.quickSettings.gray'), color: '#808080' }, + { value: CbxBackgroundColor.WHITE, label: this.t.translate('readerCbx.quickSettings.white'), color: '#ffffff' } ]; } @@ -95,10 +95,6 @@ export class CbxQuickSettingsComponent { return this.state().scrollMode === CbxScrollMode.PAGINATED; } - get isLongStrip(): boolean { - return this.state().scrollMode === CbxScrollMode.LONG_STRIP; - } - get isPhonePortrait(): boolean { return window.innerWidth < 768 && window.innerHeight > window.innerWidth; } @@ -156,6 +152,23 @@ export class CbxQuickSettingsComponent { this.quickSettingsService.emitMagnifierLensSizeChange(size); } + onBrightnessChange(event: Event): void { + const value = +(event.target as HTMLInputElement).value; + this.quickSettingsService.emitBrightnessChange(value); + } + + onEmulateBookToggle(): void { + this.quickSettingsService.emitEmulateBookChange(!this.state().emulateBook); + } + + onClickToPaginateToggle(): void { + this.quickSettingsService.emitClickToPaginateChange(!this.state().clickToPaginate); + } + + onAutoCloseMenuToggle(): void { + this.quickSettingsService.emitAutoCloseMenuChange(!this.state().autoCloseMenu); + } + onOverlayClick(): void { this.quickSettingsService.close(); } diff --git a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.spec.ts b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.spec.ts index d4dd3f811..c3334ba1e 100644 --- a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.spec.ts +++ b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.spec.ts @@ -1,4 +1,4 @@ -import {describe, expect, it} from 'vitest'; +import { describe, expect, it } from 'vitest'; import { CbxBackgroundColor, @@ -11,7 +11,7 @@ import { CbxScrollMode, CbxSlideshowInterval, } from '../../../../settings/user-management/user.service'; -import {CbxQuickSettingsService} from './cbx-quick-settings.service'; +import { CbxQuickSettingsService } from './cbx-quick-settings.service'; describe('CbxQuickSettingsService', () => { it('tracks visibility and settings state and resets cleanly', () => { @@ -27,6 +27,10 @@ describe('CbxQuickSettingsService', () => { service.setSlideshowInterval(CbxSlideshowInterval.TEN_SECONDS); service.setMagnifierZoom(CbxMagnifierZoom.ZOOM_4X); service.setMagnifierLensSize(CbxMagnifierLensSize.EXTRA_LARGE); + service.setBrightness(50); + service.setEmulateBook(true); + service.setClickToPaginate(true); + service.setAutoCloseMenu(true); expect(service.visible()).toBe(true); expect(service.state()).toEqual({ @@ -38,7 +42,11 @@ describe('CbxQuickSettingsService', () => { readingDirection: CbxReadingDirection.RTL, slideshowInterval: CbxSlideshowInterval.TEN_SECONDS, magnifierZoom: CbxMagnifierZoom.ZOOM_4X, - magnifierLensSize: CbxMagnifierLensSize.EXTRA_LARGE + magnifierLensSize: CbxMagnifierLensSize.EXTRA_LARGE, + brightness: 50, + emulateBook: true, + clickToPaginate: true, + autoCloseMenu: true }); service.close(); @@ -55,7 +63,11 @@ describe('CbxQuickSettingsService', () => { readingDirection: CbxReadingDirection.LTR, slideshowInterval: CbxSlideshowInterval.FIVE_SECONDS, magnifierZoom: CbxMagnifierZoom.ZOOM_3X, - magnifierLensSize: CbxMagnifierLensSize.MEDIUM + magnifierLensSize: CbxMagnifierLensSize.MEDIUM, + brightness: 100, + emulateBook: false, + clickToPaginate: false, + autoCloseMenu: false }); }); @@ -82,7 +94,7 @@ describe('CbxQuickSettingsService', () => { service.magnifierLensSizeChange$.subscribe(value => lensSizeEvents.push(value)); service.emitFitModeChange(CbxFitMode.ACTUAL_SIZE); - service.emitScrollModeChange(CbxScrollMode.LONG_STRIP); + service.emitScrollModeChange(CbxScrollMode.INFINITE); service.emitPageViewModeChange(CbxPageViewMode.TWO_PAGE); service.emitPageSpreadChange(CbxPageSpread.EVEN); service.emitBackgroundColorChange(CbxBackgroundColor.WHITE); @@ -92,7 +104,7 @@ describe('CbxQuickSettingsService', () => { service.emitMagnifierLensSizeChange(CbxMagnifierLensSize.LARGE); expect(fitModeEvents).toEqual([CbxFitMode.ACTUAL_SIZE]); - expect(scrollModeEvents).toEqual([CbxScrollMode.LONG_STRIP]); + expect(scrollModeEvents).toEqual([CbxScrollMode.INFINITE]); expect(pageViewEvents).toEqual([CbxPageViewMode.TWO_PAGE]); expect(pageSpreadEvents).toEqual([CbxPageSpread.EVEN]); expect(backgroundEvents).toEqual([CbxBackgroundColor.WHITE]); diff --git a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts index cc0daceb0..737423937 100644 --- a/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts +++ b/frontend/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts @@ -12,6 +12,10 @@ export interface CbxQuickSettingsState { slideshowInterval: CbxSlideshowInterval; magnifierZoom: CbxMagnifierZoom; magnifierLensSize: CbxMagnifierLensSize; + brightness: number; + emulateBook: boolean; + clickToPaginate: boolean; + autoCloseMenu: boolean; } @Injectable() @@ -25,7 +29,11 @@ export class CbxQuickSettingsService { readingDirection: CbxReadingDirection.LTR, slideshowInterval: CbxSlideshowInterval.FIVE_SECONDS, magnifierZoom: CbxMagnifierZoom.ZOOM_3X, - magnifierLensSize: CbxMagnifierLensSize.MEDIUM + magnifierLensSize: CbxMagnifierLensSize.MEDIUM, + brightness: 100, + emulateBook: false, + clickToPaginate: false, + autoCloseMenu: false }; private readonly _state = signal(this.defaultState); @@ -61,6 +69,18 @@ export class CbxQuickSettingsService { private _magnifierLensSizeChange = new Subject(); magnifierLensSizeChange$ = this._magnifierLensSizeChange.asObservable(); + private _brightnessChange = new Subject(); + brightnessChange$ = this._brightnessChange.asObservable(); + + private _emulateBookChange = new Subject(); + emulateBookChange$ = this._emulateBookChange.asObservable(); + + private _clickToPaginateChange = new Subject(); + clickToPaginateChange$ = this._clickToPaginateChange.asObservable(); + + private _autoCloseMenuChange = new Subject(); + autoCloseMenuChange$ = this._autoCloseMenuChange.asObservable(); + show(): void { this._visible.set(true); } @@ -109,6 +129,22 @@ export class CbxQuickSettingsService { this.updateState({magnifierLensSize: size}); } + setBrightness(value: number): void { + this.updateState({brightness: value}); + } + + setEmulateBook(value: boolean): void { + this.updateState({emulateBook: value}); + } + + setClickToPaginate(value: boolean): void { + this.updateState({clickToPaginate: value}); + } + + setAutoCloseMenu(value: boolean): void { + this.updateState({autoCloseMenu: value}); + } + // Actions emitted from component emitFitModeChange(mode: CbxFitMode): void { this._fitModeChange.next(mode); @@ -146,6 +182,22 @@ export class CbxQuickSettingsService { this._magnifierLensSizeChange.next(size); } + emitBrightnessChange(value: number): void { + this._brightnessChange.next(value); + } + + emitEmulateBookChange(value: boolean): void { + this._emulateBookChange.next(value); + } + + emitClickToPaginateChange(value: boolean): void { + this._clickToPaginateChange.next(value); + } + + emitAutoCloseMenuChange(value: boolean): void { + this._autoCloseMenuChange.next(value); + } + reset(): void { this._state.set(this.defaultState); this._visible.set(false); diff --git a/frontend/src/app/features/readers/cbx-reader/models/cbx-page-dimension.model.ts b/frontend/src/app/features/readers/cbx-reader/models/cbx-page-dimension.model.ts new file mode 100644 index 000000000..d816ac55a --- /dev/null +++ b/frontend/src/app/features/readers/cbx-reader/models/cbx-page-dimension.model.ts @@ -0,0 +1,6 @@ +export interface CbxPageDimension { + pageNumber: number; + width: number; + height: number; + wide: boolean; +} diff --git a/frontend/src/app/features/readers/cbx-reader/renderers/canvas-renderer.component.ts b/frontend/src/app/features/readers/cbx-reader/renderers/canvas-renderer.component.ts new file mode 100644 index 000000000..0ab47bb8f --- /dev/null +++ b/frontend/src/app/features/readers/cbx-reader/renderers/canvas-renderer.component.ts @@ -0,0 +1,133 @@ +import {Component, Input, OnChanges, SimpleChanges, ViewChild, ElementRef, AfterViewInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {CbxPageSplitOption} from '../../../settings/user-management/user.service'; + +export type CanvasSplitState = 'NO_SPLIT' | 'LEFT_PART' | 'RIGHT_PART'; + +/** + * Renders a wide page image on a , optionally showing only + * the left or right half depending on the split state. + * + * Browser canvas limits respected: + * Safari: 4096×4096 Others: 16384×16384 + */ +@Component({ + selector: 'app-canvas-renderer', + standalone: true, + imports: [CommonModule], + template: ` + + + `, + styles: [` + :host { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + .canvas-page { + object-fit: contain; + max-width: 100%; + max-height: 100%; + } + .canvas-hidden { + visibility: hidden; + } + `] +}) +export class CanvasRendererComponent implements OnChanges, AfterViewInit { + @Input() imageUrl = ''; + @Input() splitState: CanvasSplitState = 'NO_SPLIT'; + @Input() splitOption: CbxPageSplitOption = CbxPageSplitOption.NO_SPLIT; + + @ViewChild('canvas', {static: true}) canvasRef!: ElementRef; + + imageLoaded = false; + private currentImage: HTMLImageElement | null = null; + + private static readonly MAX_CANVAS_SAFARI = 4096; + private static readonly MAX_CANVAS_OTHER = 16384; + + ngAfterViewInit(): void { + if (this.imageUrl) { + this.loadAndDraw(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['imageUrl'] || changes['splitState'] || changes['splitOption']) { + if (changes['imageUrl'] && this.currentImage?.src !== this.imageUrl) { + this.loadAndDraw(); + } else if (this.currentImage) { + this.drawImage(this.currentImage); + } + } + } + + private loadAndDraw(): void { + if (!this.imageUrl) return; + + this.imageLoaded = false; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + this.currentImage = img; + this.drawImage(img); + this.imageLoaded = true; + }; + img.src = this.imageUrl; + } + + private drawImage(img: HTMLImageElement): void { + const canvas = this.canvasRef?.nativeElement; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const maxDim = this.getMaxCanvasSize(); + const srcW = img.naturalWidth; + const srcH = img.naturalHeight; + + if (this.splitState === 'NO_SPLIT' || this.splitOption === CbxPageSplitOption.NO_SPLIT) { + // Draw full image + const w = Math.min(srcW, maxDim); + const h = Math.min(srcH, maxDim); + canvas.width = w; + canvas.height = h; + ctx.drawImage(img, 0, 0, srcW, srcH, 0, 0, w, h); + return; + } + + // Split: draw only left or right half + const halfW = Math.floor(srcW / 2); + const outW = Math.min(halfW, maxDim); + const outH = Math.min(srcH, maxDim); + canvas.width = outW; + canvas.height = outH; + + const isLeftToRight = this.splitOption === CbxPageSplitOption.SPLIT_LEFT_TO_RIGHT + || this.splitOption === CbxPageSplitOption.FIT_SPLIT; + const showLeft = isLeftToRight + ? this.splitState === 'LEFT_PART' + : this.splitState === 'RIGHT_PART'; + + const srcX = showLeft ? 0 : halfW; + ctx.drawImage(img, srcX, 0, halfW, srcH, 0, 0, outW, outH); + } + + private getMaxCanvasSize(): number { + const ua = navigator.userAgent; + if (/Safari/.test(ua) && !/Chrome/.test(ua)) { + return CanvasRendererComponent.MAX_CANVAS_SAFARI; + } + return CanvasRendererComponent.MAX_CANVAS_OTHER; + } +} diff --git a/frontend/src/app/features/readers/ebook-reader/core/event.service.ts b/frontend/src/app/features/readers/ebook-reader/core/event.service.ts index c92259282..1da9eeb1d 100644 --- a/frontend/src/app/features/readers/ebook-reader/core/event.service.ts +++ b/frontend/src/app/features/readers/ebook-reader/core/event.service.ts @@ -69,7 +69,8 @@ export type ViewEvent = | { type: 'go-last-section' } | { type: 'toggle-toc' } | { type: 'toggle-search' } - | { type: 'toggle-notes' }; + | { type: 'toggle-notes' } + | { type: 'toggle-immersive' }; interface ViewCallbacks { prev: () => void; @@ -218,6 +219,9 @@ export class ReaderEventService { } else if (k === 'n' || k === 'N') { this.eventSubject.next({type: 'toggle-notes'}); event.preventDefault(); + } else if (k === 'i' || k === 'I') { + this.eventSubject.next({type: 'toggle-immersive'}); + event.preventDefault(); } else if (k === '?') { this.eventSubject.next({type: 'toggle-shortcuts-help'}); event.preventDefault(); diff --git a/frontend/src/app/features/readers/ebook-reader/dialogs/shortcuts-help.component.ts b/frontend/src/app/features/readers/ebook-reader/dialogs/shortcuts-help.component.ts index 445bf0201..d1e7af9e7 100644 --- a/frontend/src/app/features/readers/ebook-reader/dialogs/shortcuts-help.component.ts +++ b/frontend/src/app/features/readers/ebook-reader/dialogs/shortcuts-help.component.ts @@ -53,6 +53,7 @@ export class EbookShortcutsHelpComponent { title: this.t.translate('readerEbook.shortcutsHelp.display'), shortcuts: [ {keys: ['F'], description: this.t.translate('readerEbook.shortcutsHelp.toggleFullscreen')}, + {keys: ['I'], description: this.t.translate('readerEbook.shortcutsHelp.toggleImmersive')}, {keys: ['Escape'], description: this.t.translate('readerEbook.shortcutsHelp.exitFullscreenCloseDialogs')} ] }, diff --git a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.html b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.html index b68e660d7..81a42780f 100644 --- a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.html +++ b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.html @@ -1,12 +1,13 @@
@if (isLoading) { diff --git a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.scss b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.scss index cbd8947a2..5ef081281 100644 --- a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.scss +++ b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.scss @@ -148,3 +148,14 @@ foliate-view::part(foot) { min-height: 32px; box-sizing: border-box; } + +.reader.immersive { + ::ng-deep foliate-view::part(head), + ::ng-deep foliate-view::part(foot) { + display: none !important; + } + + .bookmark-indicator { + display: none; + } +} diff --git a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts index 324758029..e3a5579e7 100644 --- a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts +++ b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts @@ -33,6 +33,7 @@ import {NoteDialogResult, ReaderNoteDialogComponent} from './dialogs/note-dialog import {EbookShortcutsHelpComponent} from './dialogs/shortcuts-help.component'; import {TranslocoPipe} from '@jsverse/transloco'; import {RelocateProgressData} from './state/progress.service'; +import {WakeLockService} from '../../../shared/service/wake-lock.service'; @Component({ selector: 'app-ebook-reader', @@ -83,6 +84,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy { private selectionService = inject(ReaderSelectionService); private headerService = inject(ReaderHeaderService); private noteService = inject(ReaderNoteService); + private wakeLockService = inject(WakeLockService); public sidebarService = inject(ReaderSidebarService); public leftSidebarService = inject(ReaderLeftSidebarService); @@ -108,6 +110,8 @@ export class EbookReaderComponent implements OnInit, OnDestroy { sectionFractions: number[] = []; isFullscreen = false; showShortcutsHelp = false; + immersiveMode = false; + private immersiveAutoHideTimer?: ReturnType; readonly readerState = this.stateService.state; readonly selectionState = this.selectionService.state; @@ -161,6 +165,9 @@ export class EbookReaderComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy$)) .subscribe(() => this.showShortcutsHelp = true); + // Enable wake lock after a short delay + setTimeout(() => this.wakeLockService.enable(), 1000); + this.isLoading = true; this.initializeFoliate().pipe( switchMap(() => this.epubCustomFontService.loadAndCacheFonts()), @@ -180,6 +187,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.wakeLockService.disable(); this.destroy$.next(); this.destroy$.complete(); this.viewManager.destroy(); @@ -193,6 +201,9 @@ export class EbookReaderComponent implements OnInit, OnDestroy { this.noteService.reset(); this.epubCustomFontService.cleanup(); + if (this.immersiveAutoHideTimer) { + clearTimeout(this.immersiveAutoHideTimer); + } if (this._fileUrl) { URL.revokeObjectURL(this._fileUrl); this._fileUrl = null; @@ -315,7 +326,11 @@ export class EbookReaderComponent implements OnInit, OnDestroy { }, 500); break; case 'middle-single-tap': - this.toggleHeaderNavbarPinned(); + if (this.immersiveMode) { + this.immersiveTemporaryShow(); + } else { + this.toggleHeaderNavbarPinned(); + } break; case 'text-selected': this.selectionService.handleTextSelected(event.detail, event.popupPosition); @@ -326,6 +341,9 @@ export class EbookReaderComponent implements OnInit, OnDestroy { case 'toggle-shortcuts-help': this.showShortcutsHelp = !this.showShortcutsHelp; break; + case 'toggle-immersive': + this.toggleImmersiveMode(); + break; case 'go-first-section': this.viewManager.goToSection(0).subscribe(); break; @@ -442,6 +460,24 @@ export class EbookReaderComponent implements OnInit, OnDestroy { this.visibilityManager.handleFooterZoneEnter(); } + toggleImmersiveMode(): void { + this.immersiveMode = !this.immersiveMode; + if (this.immersiveMode) { + this.visibilityManager.setImmersive(true); + } else { + this.visibilityManager.setImmersive(false); + } + } + + private immersiveTemporaryShow(): void { + if (!this.immersiveMode) return; + this.visibilityManager.temporaryShow(); + if (this.immersiveAutoHideTimer) clearTimeout(this.immersiveAutoHideTimer); + this.immersiveAutoHideTimer = setTimeout(() => { + this.visibilityManager.hideTemporary(); + }, 3000); + } + handleSelectionAction(action: TextSelectionAction): void { if (action.type === 'note') { this.noteService.openNewNoteDialog(); diff --git a/frontend/src/app/features/readers/ebook-reader/shared/visibility.util.ts b/frontend/src/app/features/readers/ebook-reader/shared/visibility.util.ts index 50e433f7b..209be42de 100644 --- a/frontend/src/app/features/readers/ebook-reader/shared/visibility.util.ts +++ b/frontend/src/app/features/readers/ebook-reader/shared/visibility.util.ts @@ -5,6 +5,7 @@ export interface HeaderFooterVisibilityState { export class ReaderHeaderFooterVisibilityManager { private isPinned = false; + private isImmersive = false; private mouseY: number; private readonly HEADER_HEIGHT = 20; @@ -31,11 +32,13 @@ export class ReaderHeaderFooterVisibilityManager { handleMouseMove(mouseY: number): void { this.mouseY = mouseY; - this.updateVisibility(); + if (!this.isImmersive) { + this.updateVisibility(); + } } handleMouseLeave(): void { - if (!this.isPinned) { + if (!this.isPinned && !this.isImmersive) { this.setHeaderVisible(false); this.setFooterVisible(false); this.notifyStateChange(); @@ -43,14 +46,14 @@ export class ReaderHeaderFooterVisibilityManager { } handleHeaderZoneEnter(): void { - if (!this.isPinned) { + if (!this.isPinned && !this.isImmersive) { this.setHeaderVisible(true); this.notifyStateChange(); } } handleFooterZoneEnter(): void { - if (!this.isPinned) { + if (!this.isPinned && !this.isImmersive) { this.setFooterVisible(true); this.notifyStateChange(); } @@ -61,6 +64,37 @@ export class ReaderHeaderFooterVisibilityManager { this.updateVisibility(); } + unpinIfPinned(): void { + if (this.isPinned) { + this.isPinned = false; + this.updateVisibility(); + } + } + + setImmersive(immersive: boolean): void { + this.isImmersive = immersive; + if (immersive) { + this.isPinned = false; + this.setHeaderVisible(false); + this.setFooterVisible(false); + this.notifyStateChange(); + } + } + + temporaryShow(): void { + this.setHeaderVisible(true); + this.setFooterVisible(true); + this.notifyStateChange(); + } + + hideTemporary(): void { + if (this.isImmersive) { + this.setHeaderVisible(false); + this.setFooterVisible(false); + this.notifyStateChange(); + } + } + getVisibilityState(): HeaderFooterVisibilityState { return { headerVisible: this.headerVisible, diff --git a/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.html b/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.html index e054aa0a6..f47fa1732 100644 --- a/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.html +++ b/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.html @@ -1,79 +1,152 @@ @if (isLoading) { -
- -
+
+ +
} @if (!isLoading) { - +
+ + + + + @if (viewerMode === 'book') { + +
+ } + + @if (viewerMode === 'document') { + +
+ } + + + @if (viewerMode === 'book') { + + } + - -
-
+ +
+
-
- -
- - - - @if (canPrint) { - - } - - - - - - - - - -
- -
+ {{ bookTitle }} +
-
- - +
+ +
-} + + + +
+} diff --git a/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.scss b/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.scss index ac259a0b3..69eeb71e4 100644 --- a/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.scss +++ b/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.scss @@ -1,46 +1,878 @@ +@use 'sass:color'; + +/* stylelint-disable selector-id-pattern, selector-class-pattern -- targets ngx-extended-pdf-viewer / pdf.js internals */ + +// Design tokens matching CBX/E-Book readers +$header-height: 38px; +$footer-height: 52px; +$gradient-bg: linear-gradient(135deg, #2d2d2d 0%, #1f1f1f 100%); +$border-color: #404040; +$text-primary: rgb(255 255 255 / 95%); +$text-secondary: rgb(255 255 255 / 60%); +$active-color: #4a90e2; +$hover-bg: rgb(255 255 255 / 12%); +$transition-fast: 150ms ease; + +// --- Loading --- + .loading-container { display: flex; justify-content: center; align-items: center; - height: 100vh; - position: relative; + height: 100dvh; background: #1a1a1a; } -::ng-deep .toolbar-btn, -::ng-deep .close-reader-btn { +// --- Reader container --- + +.pdf-reader-container { + position: fixed; + inset: 0; display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; + flex-direction: column; + background: #1a1a1a; + z-index: 900; +} + +// --- Trigger zones --- + +.header-trigger-zone, +.footer-trigger-zone { + position: fixed; + left: 0; + right: 0; + z-index: 1001; +} + +.header-trigger-zone { + top: 0; + height: 20px; + + &.inactive { + pointer-events: none; + } +} + +.footer-trigger-zone { + bottom: 0; + height: 30px; + + &.inactive { + pointer-events: none; + } +} + +// --- Header --- +// Child element styles for .pdf-header-toolbar (positioning is in ::ng-deep #toolbarViewer) + +.pdf-header-toolbar { + .header-left, + .header-right { + display: flex; + align-items: center; + gap: 2px; + z-index: 1; + flex-shrink: 0; + } + + .header-right { + // Style zoom toolbar within header + ::ng-deep { + pdf-zoom-toolbar { + display: flex; + align-items: center; + } + + .zoom { + display: flex; + align-items: center; + gap: 2px; + } + + #scaleSelect { + background: rgb(255 255 255 / 8%) !important; + color: #fff !important; + border: 1px solid rgb(255 255 255 / 15%) !important; + border-radius: 4px !important; + padding: 2px 4px !important; + font-size: 12px !important; + cursor: pointer; + height: 26px !important; + max-width: 85px; + appearance: none; + + option { + background: #2d2d2d; + color: #fff; + } + } + } + } + + .book-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + max-width: 40%; + padding: 0 12px; + text-align: center; + font-size: 14px; + font-weight: 500; + color: inherit; + pointer-events: none; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .separator { + width: 1px; + height: 18px; + background: rgb(255 255 255 / 15%); + margin: 0 4px; + } +} + +// --- Icon buttons --- + +.icon-btn { + background: none; border: none; - border-radius: 6px; - background: transparent; - color: #2a2a2a; + color: inherit; cursor: pointer; - margin-left: 4px; - margin-right: 2px; - transition: background-color 0.2s ease, color 0.2s ease; + padding: 7px; + border-radius: 6px; + transition: background $transition-fast; + display: flex; + align-items: center; + justify-content: center; &:hover { - background: rgb(0 0 0 / 10%); + background: $hover-bg; } - svg { - flex-shrink: 0; + &.close-btn:hover { + background: rgb(239 68 68 / 20%); + color: #ef4444; } } -::ng-deep .pdf-dark .toolbar-btn, -::ng-deep .pdf-dark .close-reader-btn { - color: #e8e8e8; +// --- Override pdf.js component button styles --- - &:hover { - background: rgb(255 255 255 / 15%); +::ng-deep .pdf-btn-wrap { + button { + background: none !important; + border: none !important; + color: #fff !important; + cursor: pointer; + width: 30px !important; + height: 30px !important; + padding: 0 !important; + border-radius: 6px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: background $transition-fast !important; + margin: 0 !important; + box-shadow: none !important; + opacity: 1 !important; + + &:hover { + background: $hover-bg !important; + } + + &.toggled, + &[aria-pressed="true"] { + color: $active-color !important; + background: rgb(74 144 226 / 15%) !important; + } + + span { + display: none !important; + } + + &::before { + color: inherit !important; + opacity: 1 !important; + } + } +} + +// --- Footer --- + +.pdf-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: $footer-height; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + z-index: 1002; + background: $gradient-bg; + border-top: 1px solid $border-color; + box-shadow: 0 -2px 8px rgb(0 0 0 / 30%); + color: $text-primary; + transform: translateY(100%); + opacity: 0; + pointer-events: none; + transition: transform 0.25s ease-out, opacity 0.25s ease-out; + + &.visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + } +} + +.page-nav-left { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.nav-btn { + background: rgb(51 51 51 / 90%); + border: 1px solid #555; + color: $text-primary; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + transition: all $transition-fast; + display: flex; + align-items: center; + justify-content: center; + + &:hover:not(:disabled) { + background: #4a4a4a; + border-color: #666; + } + + &:disabled { + background: #2a2a2a; + color: #666; + cursor: not-allowed; + border-color: #333; + } +} + +.page-info { + padding: 4px 12px; + background: rgb(255 255 255 / 10%); + border-radius: 4px; + font-size: 13px; + font-weight: 500; + min-width: 90px; + text-align: center; + + .current { + color: $active-color; + font-weight: 600; + } + + .total { + color: $text-secondary; } } -::ng-deep .close-reader-btn:hover { - color: #ff6b6b; +.page-controls { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + justify-content: center; +} + +.footer-spacer { + flex: 0 0 auto; + width: 0; +} + +.slider-wrapper { + flex: 1; + display: flex; + align-items: center; + position: relative; + + .page-slider { + width: 100%; + border-radius: 3px; + outline: none; + appearance: none; + background: none; + cursor: pointer; + + &::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + background: rgb(255 255 255 / 12%); + border-radius: 3px; + cursor: pointer; + } + + &::-moz-range-track { + width: 100%; + height: 6px; + background: rgb(255 255 255 / 12%); + border-radius: 3px; + cursor: pointer; + } + + &::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: $active-color; + cursor: pointer; + box-shadow: 0 2px 6px rgb(0 0 0 / 30%); + transition: transform $transition-fast; + margin-top: calc((6px - 18px) / 2); + } + + &::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: $active-color; + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgb(0 0 0 / 30%); + transition: transform $transition-fast; + } + } + + datalist { + display: flex; + justify-content: space-between; + position: absolute; + width: 100%; + left: 0; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + height: 10px; + + option { + width: 0; + position: relative; + padding: 0; + + &::before { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 10px; + background: rgb(255 255 255 / 50%); + top: 0; + } + } + } } + +.viewer-mode-toggle { + display: flex; + align-items: center; + + .toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + gap: 6px; + padding: 0 12px; + border: 1px solid var(--surface-border); + background: var(--surface-card); + color: var(--text-color-secondary); + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.2s; + height: 30px; + + &:hover { + color: var(--text-color); + background: var(--surface-hover); + } + + &.active { + background: var(--primary-color); + color: var(--primary-color-text); + border-color: var(--primary-color); + z-index: 1; + } + + &.toggle-book { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-right: none; + } + + &.toggle-doc { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + } + } +} + +.embed-pdf-warning { + position: absolute; + bottom: 80px; + left: 24px; + z-index: 1001; + background-color: var(--surface-card); + color: var(--text-color-secondary); + border: 1px solid var(--surface-border); + padding: 8px 16px; + border-radius: 20px; + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 0.85rem; + box-shadow: 0 4px 6px rgb(0 0 0 / 10%); + pointer-events: none; + + i { + font-size: 1rem; + color: var(--primary-color); + } +} + +.embed-close-btn { + position: fixed; + top: 4px; + right: 12px; + z-index: 1010; + color: $text-primary; + padding: 7px; +} + +#embedpdf-viewer { + width: 100%; + height: 100%; + flex: 1; + display: flex; + + iframe { + width: 100%; + height: 100%; + border: none; + } +} + + +.goto-page { + display: flex; + align-items: center; + gap: 4px; + background: rgb(51 51 51 / 90%); + border-radius: 4px; + padding: 2px; + border: 1px solid #555; + + .page-input { + width: 55px; + padding: 4px 6px; + border: none; + border-radius: 3px; + background: #404040; + color: $text-primary; + font-size: 12px; + text-align: center; + + &:focus { + outline: none; + background: #4a4a4a; + } + + &::placeholder { + color: rgb(255 255 255 / 30%); + } + } + + .go-btn { + padding: 4px 8px; + border: none; + border-radius: 3px; + background: $active-color; + color: #fff; + font-size: 11px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + transition: background $transition-fast; + + &:hover:not(:disabled) { + background: color.adjust($active-color, $lightness: -10%); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } +} + +// --- Override pdf.js viewer inner styles --- + +::ng-deep { + // Make pdf viewer fill container + ngx-extended-pdf-viewer { + flex: 1; + display: block; + overflow: hidden; + + #mainContainer { + background: #1a1a1a !important; + } + + // Override pdf.js defaults on #toolbarViewer - our custom fixed header + #toolbarViewer { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + height: $header-height !important; + min-height: 0 !important; + box-sizing: border-box !important; + padding: 0 16px !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + z-index: 1002 !important; + background: $gradient-bg !important; + border-bottom: 1px solid $border-color !important; + box-shadow: 0 2px 8px rgb(0 0 0 / 30%) !important; + color: #fff !important; + opacity: 1 !important; + pointer-events: auto !important; + transform: translateY(0) !important; + transition: opacity 0.25s ease-out, transform 0.25s ease-out !important; + + &.chrome-hidden { + opacity: 0 !important; + pointer-events: none !important; + transform: translateY(-100%) !important; + } + } + + // Style the annotation editor params toolbars (color picker, thickness, etc.) + .editorParamsToolbar { + position: fixed !important; + top: $header-height !important; + right: 8px !important; + left: auto !important; + z-index: 1003 !important; + background: #2d2d2d !important; + border: 1px solid $border-color !important; + border-radius: 0 0 8px 8px !important; + box-shadow: 0 4px 12px rgb(0 0 0 / 40%) !important; + padding: 8px 12px !important; + color: #fff !important; + min-width: 180px !important; + + .editorParamsToolbarContainer { + display: flex; + flex-direction: column; + gap: 8px; + } + + .editorParamsLabel { + color: $text-secondary !important; + font-size: 12px !important; + } + + .editorParamsSetter { + display: flex; + align-items: center; + gap: 8px; + } + + .editorParamsSlider { + flex: 1; + accent-color: $active-color; + } + + .editorParamsColor { + border: 1px solid #555 !important; + border-radius: 4px !important; + cursor: pointer !important; + } + + // Highlight color picker dots + .colorPicker { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + + button { + width: 24px !important; + height: 24px !important; + min-width: 24px !important; + border-radius: 50% !important; + border: 2px solid transparent !important; + padding: 0 !important; + cursor: pointer !important; + + &[aria-selected="true"], + &.selected { + border-color: #fff !important; + box-shadow: 0 0 4px rgb(255 255 255 / 40%) !important; + } + } + } + + // Toggle button for "show all highlights" + .toggle-button { + background: #555 !important; + border: none !important; + border-radius: 12px !important; + width: 36px !important; + height: 20px !important; + cursor: pointer !important; + position: relative !important; + + &[aria-pressed="true"] { + background: $active-color !important; + } + } + + .thicknessPicker { + flex: 1; + } + + .toggler { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .divider { + height: 1px; + background: $border-color; + margin: 4px 0; + } + + // Generic button styling inside params toolbars + button.secondaryToolbarButton, + button.toolbarButton { + background: none !important; + color: #fff !important; + border: none !important; + border-radius: 6px !important; + padding: 6px 10px !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; + gap: 6px !important; + width: 100% !important; + + &:hover { + background: $hover-bg !important; + } + + span { + display: inline !important; + color: inherit !important; + } + } + + // Injected close button wrapper + .custom-close-btn-wrapper { + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + margin-bottom: 6px; + border-bottom: 1px solid rgb(255 255 255 / 10%); + padding-bottom: 6px; + } + + // Injected close button + .custom-close-btn { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 34px !important; + height: 34px !important; + padding: 0 !important; + background: transparent !important; + border: none !important; + border-radius: 4px !important; + + svg { + display: block; + } + + &:hover { + background: rgb(239 68 68 / 20%) !important; + color: #ef4444 !important; + } + } + } + + // Style the secondary toolbar dropdown + #secondaryToolbar { + top: $header-height !important; + background: #2d2d2d !important; + border: 1px solid $border-color !important; + border-radius: 0 0 8px 8px !important; + box-shadow: 0 4px 16px rgb(0 0 0 / 40%) !important; + padding: 4px !important; + + button { + background: none !important; + border: none !important; + color: #fff !important; + border-radius: 6px !important; + padding: 8px 12px !important; + font-size: 13px !important; + width: 100% !important; + text-align: left !important; + display: flex !important; + align-items: center !important; + gap: 8px !important; + + &:hover { + background: $hover-bg !important; + } + + &[aria-pressed="true"], + &.toggled { + color: $active-color !important; + background: rgb(74 144 226 / 15%) !important; + } + + span { + display: inline !important; + color: inherit !important; + } + } + } + + // Style the find bar + #findbar { + background: #2d2d2d !important; + border: 1px solid $border-color !important; + border-radius: 0 0 8px 8px !important; + box-shadow: 0 4px 12px rgb(0 0 0 / 30%) !important; + padding: 8px !important; + top: 0 !important; + + input { + background: #404040 !important; + color: #fff !important; + border: 1px solid #555 !important; + border-radius: 4px !important; + padding: 4px 8px !important; + } + + button { + background: none !important; + color: #fff !important; + border: none !important; + border-radius: 4px !important; + + &:hover { + background: $hover-bg !important; + } + } + + label { + color: $text-secondary !important; + } + } + + // Style the sidebar + #sidebarContainer { + background: #1f1f1f !important; + border-right: 1px solid $border-color !important; + + #toolbarSidebar { + background: #2d2d2d !important; + border-bottom: 1px solid $border-color !important; + + button { + color: #fff !important; + + &.toggled { + color: $active-color !important; + } + } + } + + #sidebarContent { + background: #1f1f1f !important; + } + + .treeItem { + color: #fff !important; + + >a { + color: #fff !important; + + &:hover { + background: $hover-bg !important; + } + } + } + } + + // Hide the default toolbar container chrome completely - our custom toolbar is positioned fixed + #toolbarContainer { + position: absolute !important; + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + background: transparent !important; + overflow: visible !important; + } + } +} + +// --- Responsive --- + +@media (width <= 768px) { + .pdf-footer { + height: auto; + padding: 8px 12px; + flex-wrap: wrap; + gap: 8px; + } + + .page-controls { + order: -1; + width: 100%; + } + + .goto-page { + display: none; + } + + .zoom-controls { + display: none; + } + + .header-right { + ::ng-deep pdf-zoom-toolbar { + display: none !important; + } + } + + .book-title { + display: none; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.ts b/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.ts index 9074e4e70..edbe25000 100644 --- a/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.ts +++ b/frontend/src/app/features/readers/pdf-reader/pdf-reader.component.ts @@ -1,34 +1,40 @@ -import {Component, inject, OnDestroy, OnInit} from '@angular/core'; -import {ActivatedRoute} from '@angular/router'; -import {NgxExtendedPdfViewerModule, NgxExtendedPdfViewerService, pdfDefaultOptions, ZoomType} from 'ngx-extended-pdf-viewer'; -import {PageTitleService} from "../../../shared/service/page-title.service"; -import {BookService} from '../../book/service/book.service'; -import {forkJoin, from, Subject, Subscription} from 'rxjs'; -import {debounceTime, map, switchMap} from 'rxjs/operators'; -import {BookSetting} from '../../book/model/book.model'; -import {UserService} from '../../settings/user-management/user.service'; -import {AuthService} from '../../../shared/service/auth.service'; -import {API_CONFIG} from '../../../core/config/api-config'; -import {PdfAnnotationService} from '../../../shared/service/pdf-annotation.service'; - -import {ProgressSpinner} from 'primeng/progressspinner'; -import {MessageService} from 'primeng/api'; -import {TranslocoService, TranslocoPipe} from '@jsverse/transloco'; -import {ReadingSessionService} from '../../../shared/service/reading-session.service'; -import {Location} from '@angular/common'; - -interface SerializedPdfAnnotation extends Record { - id?: unknown; -} +import { Component, HostListener, inject, OnDestroy, OnInit, AfterViewInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NgxExtendedPdfViewerModule, NgxExtendedPdfViewerService, pdfDefaultOptions, ZoomType } from 'ngx-extended-pdf-viewer'; +import { PageTitleService } from "../../../shared/service/page-title.service"; +import { BookService } from '../../book/service/book.service'; +import { forkJoin, from, Subject, Subscription } from 'rxjs'; +import { debounceTime, map, switchMap } from 'rxjs/operators'; +import { BookSetting } from '../../book/model/book.model'; +import { UserService } from '../../settings/user-management/user.service'; +import { AuthService } from '../../../shared/service/auth.service'; +import { API_CONFIG } from '../../../core/config/api-config'; +import { PdfAnnotationService } from '../../../shared/service/pdf-annotation.service'; +import { ReaderIconComponent } from '../../readers/ebook-reader/shared/icon.component'; + +import { ProgressSpinner } from 'primeng/progressspinner'; +import { MessageService } from 'primeng/api'; +import { TranslocoService, TranslocoPipe } from '@jsverse/transloco'; +import { ReadingSessionService } from '../../../shared/service/reading-session.service'; +import { WakeLockService } from '../../../shared/service/wake-lock.service'; +import { Location } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +type EmbedPdfMessage = + | { type: 'ready' } + | { type: 'documentOpened'; pageCount?: number } + | { type: 'documentError'; error: string } + | { type: 'saved'; buffer?: ArrayBuffer } + | { type: 'saveError'; error: string }; @Component({ selector: 'app-pdf-reader', standalone: true, - imports: [NgxExtendedPdfViewerModule, ProgressSpinner, TranslocoPipe], + imports: [NgxExtendedPdfViewerModule, ProgressSpinner, TranslocoPipe, ReaderIconComponent, FormsModule], templateUrl: './pdf-reader.component.html', styleUrl: './pdf-reader.component.scss', }) -export class PdfReaderComponent implements OnInit, OnDestroy { +export class PdfReaderComponent implements OnInit, OnDestroy, AfterViewInit { constructor() { pdfDefaultOptions.rangeChunkSize = 512 * 1024; pdfDefaultOptions.disableAutoFetch = true; @@ -49,6 +55,32 @@ export class PdfReaderComponent implements OnInit, OnDestroy { bookData!: string; bookId!: number; bookFileId?: number; + bookTitle = ''; + isFullscreen = false; + viewerMode: 'book' | 'document' = 'book'; + private embedPdfIframe: HTMLIFrameElement | null = null; + private embedPdfMessageHandler?: (e: MessageEvent) => void; + private embedPdfSaveResolve?: (buffer: ArrayBuffer | null) => void; + private embedPdfSaveTimer?: ReturnType; + private embedPdfInitTime = 0; + + // Auto-hide chrome + headerVisible = true; + footerVisible = true; + private chromeAutoHideTimer?: ReturnType; + private readonly CHROME_HIDE_DELAY = 3000; + + // Footer page navigation + goToPageInput: number | null = null; + get sliderTicks(): number[] { + if (this.totalPages <= 1) return []; + const step = Math.max(1, Math.floor(this.totalPages / 10)); + const ticks: number[] = []; + for (let i = 1; i <= this.totalPages; i += step) ticks.push(i); + if (ticks[ticks.length - 1] !== this.totalPages) ticks.push(this.totalPages); + return ticks; + } + private altBookType?: string; private appSettingsSubscription!: Subscription; private annotationSaveSubject = new Subject(); @@ -66,8 +98,14 @@ export class PdfReaderComponent implements OnInit, OnDestroy { private pdfViewerService = inject(NgxExtendedPdfViewerService); private pdfAnnotationService = inject(PdfAnnotationService); private readonly t = inject(TranslocoService); + private wakeLockService = inject(WakeLockService); + private annotationToolbarObserver?: MutationObserver; ngOnInit(): void { + setTimeout(() => this.wakeLockService.enable(), 1000); + this.startChromeAutoHide(); + document.addEventListener('fullscreenchange', this.onFullscreenChange); + this.annotationSaveSubscription = this.annotationSaveSubject .pipe(debounceTime(1500)) .subscribe(() => this.persistAnnotations()); @@ -90,39 +128,266 @@ export class PdfReaderComponent implements OnInit, OnDestroy { return forkJoin([ this.bookService.getBookSetting(this.bookId, this.bookFileId!), this.userService.getMyself() - ]).pipe(map(([bookSetting, myself]) => ({book, bookSetting, myself}))); + ]).pipe(map(([bookSetting, myself]) => ({ book, bookSetting, myself }))); }) ); }) ).subscribe({ - next: ({book, bookSetting, myself}) => { - const pdfMeta = book; - const pdfPrefs = bookSetting; - - this.pageTitle.setBookPageTitle(pdfMeta); - - const globalOrIndividual = myself.userSettings.perBookSetting.pdf; - if (globalOrIndividual === 'Global') { - this.zoom = myself.userSettings.pdfReaderSetting.pageZoom || 'page-fit'; - this.spread = myself.userSettings.pdfReaderSetting.pageSpread || 'odd'; - } else { - this.zoom = pdfPrefs.pdfSettings?.zoom || myself.userSettings.pdfReaderSetting.pageZoom || 'page-fit'; - this.spread = pdfPrefs.pdfSettings?.spread || myself.userSettings.pdfReaderSetting.pageSpread || 'odd'; + next: ({ book, bookSetting, myself }) => { + const pdfMeta = book; + const pdfPrefs = bookSetting; + + this.pageTitle.setBookPageTitle(pdfMeta); + this.bookTitle = pdfMeta.metadata?.title || ''; + + const globalOrIndividual = myself.userSettings.perBookSetting.pdf; + if (globalOrIndividual === 'Global') { + this.zoom = myself.userSettings.pdfReaderSetting.pageZoom || 'page-fit'; + this.spread = myself.userSettings.pdfReaderSetting.pageSpread || 'off'; + } else { + this.zoom = pdfPrefs.pdfSettings?.zoom || myself.userSettings.pdfReaderSetting.pageZoom || 'page-fit'; + this.spread = pdfPrefs.pdfSettings?.spread || myself.userSettings.pdfReaderSetting.pageSpread || 'off'; + this.isDarkTheme = pdfPrefs.pdfSettings?.isDarkTheme ?? true; + } + this.canPrint = myself.permissions.canDownload || myself.permissions.admin; + this.page = pdfMeta.pdfProgress?.page || 1; + this.bookData = this.altBookType + ? `${API_CONFIG.BASE_URL}/api/v1/books/${this.bookId}/content?bookType=${this.altBookType}` + : `${API_CONFIG.BASE_URL}/api/v1/books/${this.bookId}/content`; + const token = this.authService.getInternalAccessToken(); + this.authorization = token ? `Bearer ${token}` : ''; + this.isLoading = false; + }, + error: () => { + this.messageService.add({ severity: 'error', summary: this.t.translate('common.error'), detail: this.t.translate('readerPdf.toast.failedToLoadBook') }); + this.isLoading = false; + } + }); + } + + ngAfterViewInit(): void { + this.setupAnnotationToolbarCloseObserver(); + } + + private setupAnnotationToolbarCloseObserver(): void { + this.annotationToolbarObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLElement) { + if (node.classList.contains('editorParamsToolbar')) { + this.injectCloseButton(node); + } + const toolbars = node.querySelectorAll?.('.editorParamsToolbar'); + toolbars?.forEach(t => this.injectCloseButton(t as HTMLElement)); } - this.canPrint = myself.permissions.canDownload || myself.permissions.admin; - this.page = pdfMeta.pdfProgress?.page || 1; - this.bookData = this.altBookType - ? `${API_CONFIG.BASE_URL}/api/v1/books/${this.bookId}/content?bookType=${this.altBookType}` - : `${API_CONFIG.BASE_URL}/api/v1/books/${this.bookId}/content`; - const token = this.authService.getInternalAccessToken(); - this.authorization = token ? `Bearer ${token}` : ''; - this.isLoading = false; - }, - error: () => { - this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: this.t.translate('readerPdf.toast.failedToLoadBook')}); - this.isLoading = false; + }); + }); + }); + + this.annotationToolbarObserver.observe(document.body, { childList: true, subtree: true }); + + // Also check immediately in case they are already in the DOM + setTimeout(() => { + document.querySelectorAll('.editorParamsToolbar').forEach(t => this.injectCloseButton(t as HTMLElement)); + }, 1000); + } + + private injectCloseButton(toolbar: HTMLElement): void { + if (toolbar.querySelector('.custom-close-btn-wrapper') || toolbar.querySelector('.custom-close-btn')) return; + + const wrapper = document.createElement('div'); + wrapper.className = 'custom-close-btn-wrapper'; + + const btn = document.createElement('button'); + btn.className = 'custom-close-btn icon-btn'; + btn.innerHTML = ``; + + // Attempt to translate, fallback to 'Close' + try { + btn.title = this.t.translate('common.close') || 'Close'; + } catch { + btn.title = 'Close'; + } + + btn.onclick = () => { + // Dispatching ESC doesn't always work natively for pdf.js. + // The most reliable way to close it is to click the tool that is currently active. + const activeBtn = document.querySelector(` + #editorHighlight.toggled, + #editorFreeText.toggled, + #editorInk.toggled, + #editorStamp.toggled, + .header-right button.toggled, + .header-right button[aria-pressed="true"] + `) as HTMLElement; + + if (activeBtn) { + activeBtn.click(); + } else { + // Fallback: dispatch a custom event or switch to hand tool if the button isn't found + const editorNone = document.querySelector('#editorNone') as HTMLElement; + if (editorNone) { + editorNone.click(); + } else { + // Last resort: click outside + document.body.click(); } + } + }; + + wrapper.appendChild(btn); + toolbar.prepend(wrapper); + } + + async setViewerMode(mode: 'book' | 'document') { + if (mode !== 'document' && this.embedPdfIframe) { + await this.saveEmbedPdfDocument(); + await this.destroyEmbedPdf(); + } + this.viewerMode = mode; + if (mode === 'document') { + // Pause the body-wide MutationObserver it causes massive lag when EmbedPDF mutates the DOM + this.annotationToolbarObserver?.disconnect(); + setTimeout(() => this.initEmbedPdf(), 100); + } else { + // Re-enable it for book mode + this.setupAnnotationToolbarCloseObserver(); + } + } + + private async initEmbedPdf() { + if (this.embedPdfIframe) return; + + const t0 = performance.now(); + this.embedPdfInitTime = t0; + + try { + const headers: Record = {}; + if (this.authorization) { + headers['Authorization'] = this.authorization; + } + + const response = await fetch(this.bookData, { headers, credentials: 'include' }); + if (!response.ok) throw new Error(`PDF fetch failed: ${response.status}`); + + const pdfBuffer = await response.arrayBuffer(); + const targetEl = document.getElementById('embedpdf-viewer'); + if (!targetEl) throw new Error('#embedpdf-viewer not found'); + + // Create iframe — EmbedPDF + its WASM memory live entirely inside the iframe. + // Destroying the iframe releases all WASM linear memory, solving the leak. + const iframe = document.createElement('iframe'); + iframe.src = '/assets/embedpdf-frame.html'; + iframe.style.cssText = 'width:100%;height:100%;border:none;'; + iframe.setAttribute('allow', 'fullscreen'); + + // Listen for messages from the iframe + this.embedPdfMessageHandler = (e: MessageEvent) => { + if (e.origin !== location.origin) return; + if (e.source !== iframe.contentWindow) return; + this.handleEmbedPdfMessage(e.data); + }; + window.addEventListener('message', this.embedPdfMessageHandler); + + // Wait for iframe to load before sending PDF data + await new Promise((resolve) => { + iframe.onload = () => resolve(); + targetEl.appendChild(iframe); + }); + + this.embedPdfIframe = iframe; + + // Transfer the PDF buffer to the iframe (zero-copy via Transferable) + iframe.contentWindow!.postMessage({ + type: 'init', + buffer: pdfBuffer, + wasmUrl: '/assets/pdfium/pdfium.wasm', + theme: this.isDarkTheme ? 'dark' : 'light' + }, location.origin, [pdfBuffer]); + + } catch (err) { + console.error('[EmbedPDF] FATAL:', err); + this.messageService.add({ + severity: 'error', + summary: this.t.translate('common.error'), + detail: 'Failed to load Document Viewer. Check browser console for details.' + }); + } + } + + /** Handle postMessage events from the EmbedPDF iframe */ + private handleEmbedPdfMessage(msg: EmbedPdfMessage): void { + switch (msg.type) { + case 'ready': + break; + case 'documentOpened': + break; + case 'documentError': + console.error('[EmbedPDF] Document error:', msg.error); + break; + case 'saved': + this.embedPdfSaveResolve?.(msg.buffer ?? null); + this.embedPdfSaveResolve = undefined; + break; + case 'saveError': + console.error('[EmbedPDF] Save error:', msg.error); + this.embedPdfSaveResolve?.(null); + this.embedPdfSaveResolve = undefined; + break; + } + } + + private async saveEmbedPdfDocument(): Promise { + if (!this.embedPdfIframe?.contentWindow) { + return; + } + + try { + // Request save from iframe and wait for response + const buffer: ArrayBuffer | null = await new Promise((resolve) => { + this.embedPdfSaveResolve = resolve; + this.embedPdfIframe!.contentWindow!.postMessage({ type: 'save' }, location.origin); + // Timeout after 30 seconds + const timer = setTimeout(() => { + if (this.embedPdfSaveResolve === resolve) { + resolve(null); + this.embedPdfSaveResolve = undefined; + } + }, 30000); + // Store timer so we can clear it on success + this.embedPdfSaveTimer = timer; + }); + + if (this.embedPdfSaveTimer) { + clearTimeout(this.embedPdfSaveTimer); + this.embedPdfSaveTimer = undefined; + } + + if (!buffer) { + return; + } + + const headers: Record = { 'Content-Type': 'application/pdf' }; + if (this.authorization) { + headers['Authorization'] = this.authorization; + } + + const url = this.altBookType + ? `${API_CONFIG.BASE_URL}/api/v1/books/${this.bookId}/content?bookType=${this.altBookType}` + : `${API_CONFIG.BASE_URL}/api/v1/books/${this.bookId}/content`; + + const uploadResponse = await fetch(url, { + method: 'PUT', + headers, + credentials: 'include', + body: buffer }); + if (!uploadResponse.ok) { + console.error('[EmbedPDF] Upload failed:', uploadResponse.status); + } + } catch (err) { + console.error('[EmbedPDF] Failed to save document:', err); + } } onPageChange(page: number): void { @@ -148,11 +413,24 @@ export class PdfReaderComponent implements OnInit, OnDestroy { } } + toggleDarkTheme(): void { + this.isDarkTheme = !this.isDarkTheme; + this.updateViewerSetting(); + // Sync EmbedPDF theme if active + if (this.embedPdfIframe?.contentWindow) { + this.embedPdfIframe.contentWindow.postMessage( + { type: 'setTheme', theme: this.isDarkTheme ? 'dark' : 'light' }, + location.origin + ); + } + } + private updateViewerSetting(): void { const bookSetting: BookSetting = { pdfSettings: { spread: this.spread, zoom: this.zoom, + isDarkTheme: this.isDarkTheme, } } this.bookService.updateViewerSetting(bookSetting, this.bookId).subscribe(); @@ -168,7 +446,8 @@ export class PdfReaderComponent implements OnInit, OnDestroy { const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; this.readingSessionService.startSession(this.bookId, "PDF", this.page.toString(), percentage); this.readingSessionService.updateProgress(this.page.toString(), percentage); - this.loadAnnotations(); + // Delay annotation loading to ensure annotation editor layers are initialized + setTimeout(() => this.loadAnnotations(), 800); } onAnnotationEditorEvent(): void { @@ -178,6 +457,9 @@ export class PdfReaderComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.wakeLockService.disable(); + if (this.chromeAutoHideTimer) clearTimeout(this.chromeAutoHideTimer); + if (this.annotationToolbarObserver) this.annotationToolbarObserver.disconnect(); if (this.readingSessionService.isSessionActive()) { const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; this.readingSessionService.endSession(this.page.toString(), percentage); @@ -185,14 +467,117 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.annotationSaveSubscription?.unsubscribe(); this.persistAnnotations(); + this.destroyEmbedPdf(); if (this.appSettingsSubscription) { this.appSettingsSubscription.unsubscribe(); } this.updateProgress(); + document.removeEventListener('fullscreenchange', this.onFullscreenChange); + } + + // --- Chrome auto-hide --- + + @HostListener('document:mousemove') + onMouseMove(): void { + this.showChrome(); + this.startChromeAutoHide(); + } + + showChrome(): void { + this.headerVisible = true; + this.footerVisible = true; + } + + hideChrome(): void { + this.headerVisible = false; + this.footerVisible = false; + } + + private startChromeAutoHide(): void { + if (this.chromeAutoHideTimer) clearTimeout(this.chromeAutoHideTimer); + this.chromeAutoHideTimer = setTimeout(() => this.hideChrome(), this.CHROME_HIDE_DELAY); + } + + onHeaderTriggerZoneEnter(): void { + this.headerVisible = true; + this.startChromeAutoHide(); + } + + onFooterTriggerZoneEnter(): void { + this.footerVisible = true; + this.startChromeAutoHide(); + } + + // --- Fullscreen --- + + toggleFullscreen(): void { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen?.(); + } else { + document.exitFullscreen?.(); + } + } + + private onFullscreenChange = (): void => { + this.isFullscreen = !!document.fullscreenElement; + }; + + // --- Footer page navigation --- + + goToFirstPage(): void { + this.page = 1; + this.onPageChange(1); + } + + goToPreviousPage(): void { + if (this.page > 1) { + const p = this.page - 1; + this.page = p; + this.onPageChange(p); + } + } + + goToNextPage(): void { + if (this.page < this.totalPages) { + const p = this.page + 1; + this.page = p; + this.onPageChange(p); + } + } + + goToLastPage(): void { + this.page = this.totalPages; + this.onPageChange(this.totalPages); + } + + onSliderChange(event: Event): void { + const value = +(event.target as HTMLInputElement).value; + this.page = value; + this.onPageChange(value); + } + + onGoToPage(): void { + if (this.goToPageInput && this.goToPageInput >= 1 && this.goToPageInput <= this.totalPages) { + this.page = this.goToPageInput; + this.onPageChange(this.goToPageInput); + this.goToPageInput = null; + } + } + + // --- Rotation --- + + rotateClockwise(): void { + this.rotation = ((this.rotation + 90) % 360) as 0 | 90 | 180 | 270; } - closeReader = (): void => { + closeReader = async (): Promise => { + if (this.embedPdfIframe) { + await this.saveEmbedPdfDocument(); + await this.destroyEmbedPdf(); + } else { + this.persistAnnotations(); + } if (this.readingSessionService.isSessionActive()) { const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; this.readingSessionService.endSession(this.page.toString(), percentage); @@ -202,11 +587,17 @@ export class PdfReaderComponent implements OnInit, OnDestroy { private loadAnnotations(): void { this.pdfAnnotationService.getAnnotations(this.bookId).subscribe({ - next: (response) => { + next: async (response) => { if (response?.data) { - const annotations = JSON.parse(response.data); - for (const annotation of annotations) { - this.pdfViewerService.addEditorAnnotation(annotation); + try { + const annotations = JSON.parse(response.data); + if (Array.isArray(annotations)) { + for (const annotation of annotations) { + await this.pdfViewerService.addEditorAnnotation(annotation); + } + } + } catch (e) { + console.error('[PDF Annotations] Failed to load annotations:', e); } } this.annotationsLoaded = true; @@ -217,19 +608,45 @@ export class PdfReaderComponent implements OnInit, OnDestroy { }); } + private async destroyEmbedPdf(): Promise { + + + // Remove message listener + if (this.embedPdfMessageHandler) { + window.removeEventListener('message', this.embedPdfMessageHandler); + this.embedPdfMessageHandler = undefined; + } + + // Cancel any pending save + if (this.embedPdfSaveTimer) { + clearTimeout(this.embedPdfSaveTimer); + this.embedPdfSaveTimer = undefined; + } + this.embedPdfSaveResolve?.(null); + this.embedPdfSaveResolve = undefined; + + // Remove the iframe — this destroys the entire browsing context, + // freeing all WASM linear memory, JS heap, and DOM within it. + if (this.embedPdfIframe) { + this.embedPdfIframe.remove(); + this.embedPdfIframe = null; + } + + this.embedPdfInitTime = 0; + } + private persistAnnotations(): void { - if (!this.annotationsLoaded || !this.bookId) { + if (!this.annotationsLoaded || !this.bookId || this.viewerMode === 'document') { return; } - const serialized = this.pdfViewerService.getSerializedAnnotations(); - if (serialized && serialized.length > 0) { - const cleaned = serialized.map((annotation: SerializedPdfAnnotation) => { - const {id, ...rest} = annotation; - void id; - return rest; - }); - const data = JSON.stringify(cleaned); - this.pdfAnnotationService.saveAnnotations(this.bookId, data).subscribe(); + try { + const serialized = this.pdfViewerService.getSerializedAnnotations(); + if (serialized && serialized.length > 0) { + const data = JSON.stringify(serialized); + this.pdfAnnotationService.saveAnnotations(this.bookId, data).subscribe(); + } + } catch (e) { + console.error('[PDF Annotations] Failed to save annotations:', e); } } } diff --git a/frontend/src/app/features/settings/reader-preferences/cbx-reader-preferences/cbx-reader-preferences-component.ts b/frontend/src/app/features/settings/reader-preferences/cbx-reader-preferences/cbx-reader-preferences-component.ts index 3e885aabf..cdbc1ab3d 100644 --- a/frontend/src/app/features/settings/reader-preferences/cbx-reader-preferences/cbx-reader-preferences-component.ts +++ b/frontend/src/app/features/settings/reader-preferences/cbx-reader-preferences/cbx-reader-preferences-component.ts @@ -48,7 +48,8 @@ export class CbxReaderPreferencesComponent { readonly cbxScrollModes = [ {name: 'Paginated', key: CbxScrollMode.PAGINATED, icon: 'pi pi-book', translationKey: 'paginated'}, - {name: 'Infinite', key: CbxScrollMode.INFINITE, icon: 'pi pi-sort-alt', translationKey: 'infinite'} + {name: 'Infinite', key: CbxScrollMode.INFINITE, icon: 'pi pi-sort-alt', translationKey: 'infinite'}, + {name: 'Long Strip', key: CbxScrollMode.LONG_STRIP, icon: 'pi pi-bars', translationKey: 'longStrip'} ]; readonly cbxBackgroundColors = [ diff --git a/frontend/src/app/features/settings/user-management/user.service.ts b/frontend/src/app/features/settings/user-management/user.service.ts index f0db545bb..38ffc8868 100644 --- a/frontend/src/app/features/settings/user-management/user.service.ts +++ b/frontend/src/app/features/settings/user-management/user.service.ts @@ -1,13 +1,13 @@ -import {computed, effect, inject, Injectable} from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {lastValueFrom, Observable, throwError} from 'rxjs'; -import {API_CONFIG} from '../../../core/config/api-config'; -import {Library} from '../../book/model/library.model'; -import {catchError, map, tap} from 'rxjs/operators'; -import {AuthService} from '../../../shared/service/auth.service'; -import {DashboardConfig} from '../../dashboard/models/dashboard-config.model'; -import {injectQuery, queryOptions, QueryClient} from '@tanstack/angular-query-experimental'; -import {CURRENT_USER_QUERY_KEY} from './user-query-keys'; +import { computed, effect, inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { lastValueFrom, Observable, throwError } from 'rxjs'; +import { API_CONFIG } from '../../../core/config/api-config'; +import { Library } from '../../book/model/library.model'; +import { catchError, map, tap } from 'rxjs/operators'; +import { AuthService } from '../../../shared/service/auth.service'; +import { DashboardConfig } from '../../dashboard/models/dashboard-config.model'; +import { injectQuery, queryOptions, QueryClient } from '@tanstack/angular-query-experimental'; +import { CURRENT_USER_QUERY_KEY } from './user-query-keys'; export interface EntityViewPreferences { global: EntityViewPreference; @@ -64,6 +64,14 @@ export type BookFilterMode = 'and' | 'or' | 'single' | 'not'; export enum CbxPageViewMode { SINGLE_PAGE = 'SINGLE_PAGE', TWO_PAGE = 'TWO_PAGE', + TWO_PAGE_REVERSED = 'TWO_PAGE_REVERSED', +} + +export enum CbxPageSplitOption { + NO_SPLIT = 'NO_SPLIT', + FIT_SPLIT = 'FIT_SPLIT', + SPLIT_LEFT_TO_RIGHT = 'SPLIT_LEFT_TO_RIGHT', + SPLIT_RIGHT_TO_LEFT = 'SPLIT_RIGHT_TO_LEFT', } export enum CbxPageSpread { @@ -197,6 +205,11 @@ export interface CbxReaderSetting { backgroundColor?: CbxBackgroundColor; readingDirection?: CbxReadingDirection; slideshowInterval?: CbxSlideshowInterval; + pageSplitOption?: CbxPageSplitOption; + brightness?: number; + emulateBook?: boolean; + clickToPaginate?: boolean; + autoCloseMenu?: boolean; } export interface TableColumnPreference { @@ -231,33 +244,33 @@ export const ALL_FILTER_OPTION_VALUES: VisibleFilterType[] = [ ]; export const ALL_FILTER_OPTIONS: { label: string; value: VisibleFilterType }[] = [ - {label: 'Author', value: 'author'}, - {label: 'Genre', value: 'category'}, - {label: 'Series', value: 'series'}, - {label: 'Book Type', value: 'bookType'}, - {label: 'Read Status', value: 'readStatus'}, - {label: 'Personal Rating', value: 'personalRating'}, - {label: 'Library', value: 'library'}, - {label: 'Tag', value: 'tag'}, - {label: 'Age Rating', value: 'ageRating'}, - {label: 'Content Rating', value: 'contentRating'}, - {label: 'Metadata Match Score', value: 'matchScore'}, - {label: 'Publisher', value: 'publisher'}, - {label: 'Published Year', value: 'publishedDate'}, - {label: 'File Size', value: 'fileSize'}, - {label: 'Shelf', value: 'shelf'}, - {label: 'Shelf Status', value: 'shelfStatus'}, - {label: 'Language', value: 'language'}, - {label: 'Page Count', value: 'pageCount'}, - {label: 'Mood', value: 'mood'}, - {label: 'Amazon Rating', value: 'amazonRating'}, - {label: 'Goodreads Rating', value: 'goodreadsRating'}, - {label: 'Hardcover Rating', value: 'hardcoverRating'}, - {label: 'Narrator', value: 'narrator'}, - {label: 'Comic Character', value: 'comicCharacter'}, - {label: 'Comic Team', value: 'comicTeam'}, - {label: 'Comic Location', value: 'comicLocation'}, - {label: 'Comic Creator', value: 'comicCreator'} + { label: 'Author', value: 'author' }, + { label: 'Genre', value: 'category' }, + { label: 'Series', value: 'series' }, + { label: 'Book Type', value: 'bookType' }, + { label: 'Read Status', value: 'readStatus' }, + { label: 'Personal Rating', value: 'personalRating' }, + { label: 'Library', value: 'library' }, + { label: 'Tag', value: 'tag' }, + { label: 'Age Rating', value: 'ageRating' }, + { label: 'Content Rating', value: 'contentRating' }, + { label: 'Metadata Match Score', value: 'matchScore' }, + { label: 'Publisher', value: 'publisher' }, + { label: 'Published Year', value: 'publishedDate' }, + { label: 'File Size', value: 'fileSize' }, + { label: 'Shelf', value: 'shelf' }, + { label: 'Shelf Status', value: 'shelfStatus' }, + { label: 'Language', value: 'language' }, + { label: 'Page Count', value: 'pageCount' }, + { label: 'Mood', value: 'mood' }, + { label: 'Amazon Rating', value: 'amazonRating' }, + { label: 'Goodreads Rating', value: 'goodreadsRating' }, + { label: 'Hardcover Rating', value: 'hardcoverRating' }, + { label: 'Narrator', value: 'narrator' }, + { label: 'Comic Character', value: 'comicCharacter' }, + { label: 'Comic Team', value: 'comicTeam' }, + { label: 'Comic Location', value: 'comicLocation' }, + { label: 'Comic Creator', value: 'comicCreator' } ]; export const DEFAULT_VISIBLE_SORT_FIELDS: string[] = [ @@ -374,7 +387,7 @@ export class UserService { effect(() => { const token = this.token(); if (token === null) { - this.queryClient.removeQueries({queryKey: CURRENT_USER_QUERY_KEY}); + this.queryClient.removeQueries({ queryKey: CURRENT_USER_QUERY_KEY }); } }); } @@ -457,13 +470,13 @@ export class UserService { value }; this.http.put(`${this.userUrl}/${userId}/settings`, payload, { - headers: {'Content-Type': 'application/json'}, + headers: { 'Content-Type': 'application/json' }, responseType: 'text' as 'json' }).subscribe(() => { const currentUser = this.currentUser(); if (currentUser) { - const updatedSettings = {...currentUser.userSettings, [key]: value}; - const updatedUser = {...currentUser, userSettings: updatedSettings}; + const updatedSettings = { ...currentUser.userSettings, [key]: value }; + const updatedUser = { ...currentUser, userSettings: updatedSettings }; this.queryClient.setQueryData(CURRENT_USER_QUERY_KEY, updatedUser); } }); @@ -483,7 +496,7 @@ export class UserService { private serializeUserPayload(payload: T): T { const payloadRecord = payload as Record; - const nextPayload: Record = {...payloadRecord}; + const nextPayload: Record = { ...payloadRecord }; if ('permissions' in payloadRecord && payloadRecord['permissions'] && typeof payloadRecord['permissions'] === 'object') { const permissions = payloadRecord['permissions'] as User['permissions']; diff --git a/frontend/src/app/shared/layout/component/layout-topbar/app.topbar.component.html b/frontend/src/app/shared/layout/component/layout-topbar/app.topbar.component.html index 3a5f3a8e9..20c39a2ce 100644 --- a/frontend/src/app/shared/layout/component/layout-topbar/app.topbar.component.html +++ b/frontend/src/app/shared/layout/component/layout-topbar/app.topbar.component.html @@ -1,8 +1,8 @@ -
+
- + Grimmory + - - + + -
- -
    -
    - @if (user(); as currentUser) { +
    + +
      +
      + @if (user(); as currentUser) { @if (currentUser.permissions?.canAccessBookdrop || currentUser.permissions?.admin) { -
    • - - - -
    • +
    • + + + +
    • } @if (currentUser.permissions?.canManageLibrary || currentUser.permissions?.admin) { -
    • - - - -
    • +
    • + + + +
    • } @if (currentUser.permissions?.canUpload || currentUser.permissions?.admin) { -
    • - - - -
    • +
    • + + + +
    • + } } - } - @if (hasStatsAccess) { + @if (hasStatsAccess) {
    • - @if (shouldShowStatsMenu) { - + }
    • - } - @if (user(); as currentUser) { + } + @if (user(); as currentUser) { @if (currentUser.permissions?.canManageLibrary || currentUser.permissions?.admin) { -
    • - - - -
    • +
    • + + + +
    • } - } -
    • - - - -
    • -
      + } +
    • + + + +
    • +
    - + -
    -
  • - +
    +
  • + - @if (shouldShowNotificationBadge) { + @if (shouldShowNotificationBadge) { {{ completedTaskCount }} - } + } - - - -
  • + + + + -
  • - - -
  • -
  • - - -
  • -
    - -
    -
  • - - - -
  • +
  • + + +
  • +
  • + + +
  • +
    + +
    +
  • + + + +
  • - - @if (user(); as currentUser) { - @if (!currentUser.permissions?.demoUser) { -
  • - -
  • + + @if (user(); as currentUser) { + @if (!currentUser.permissions.demoUser) { +
  • + +
  • + } } - } -
  • - -
  • -
    -
- -
- - -
    - @if (user(); as currentUser) { +
  • + +
  • +
+ + +
+ + +
    + @if (user(); as currentUser) { @if (currentUser.permissions?.canAccessBookdrop || currentUser.permissions?.admin) { -
  • - -
  • +
  • + +
  • } @if (currentUser.permissions?.canManageLibrary || currentUser.permissions?.admin) { -
  • - -
  • +
  • + +
  • } @if (currentUser.permissions?.canUpload || currentUser.permissions?.admin) { -
  • - -
  • +
  • + +
  • } @if (currentUser.permissions?.canAccessLibraryStats || currentUser.permissions?.admin) { -
  • - -
  • +
  • + +
  • } @if (currentUser.permissions?.canAccessUserStats || currentUser.permissions?.admin) { -
  • - -
  • +
  • + +
  • } @if (currentUser.permissions?.canManageLibrary || currentUser.permissions?.admin) { -
  • - -
  • +
  • + +
  • } - } -
  • - -
  • -
  • - - - {{ t('documentation') }} - -
  • -
  • - - -
  • + } +
  • + +
  • +
  • + + + {{ t('documentation') }} + +
  • +
  • + + +
  • - @if (user(); as currentUser) { - @if (!currentUser.permissions?.demoUser) { -
  • - -
  • + @if (user(); as currentUser) { + @if (!currentUser.permissions.demoUser) { +
  • + +
  • + } } - } -
  • - -
  • -
-
+
  • + +
  • + + +
    -
    -
    + \ No newline at end of file diff --git a/frontend/src/app/shared/service/wake-lock.service.ts b/frontend/src/app/shared/service/wake-lock.service.ts new file mode 100644 index 000000000..f67be5d2f --- /dev/null +++ b/frontend/src/app/shared/service/wake-lock.service.ts @@ -0,0 +1,50 @@ +import {Injectable, OnDestroy} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class WakeLockService implements OnDestroy { + private wakeLock: WakeLockSentinel | null = null; + private enabled = false; + + async enable(): Promise { + if (this.enabled) return; + this.enabled = true; + + document.addEventListener('visibilitychange', this.onVisibilityChange); + await this.requestWakeLock(); + } + + async disable(): Promise { + this.enabled = false; + document.removeEventListener('visibilitychange', this.onVisibilityChange); + await this.releaseWakeLock(); + } + + ngOnDestroy(): void { + this.disable(); + } + + private async requestWakeLock(): Promise { + if (!('wakeLock' in navigator)) return; + try { + this.wakeLock = await navigator.wakeLock.request('screen'); + this.wakeLock.addEventListener('release', () => { + this.wakeLock = null; + }); + } catch { + // Wake lock request failed (e.g., page not visible) + } + } + + private async releaseWakeLock(): Promise { + if (this.wakeLock) { + await this.wakeLock.release(); + this.wakeLock = null; + } + } + + private onVisibilityChange = async (): Promise => { + if (this.enabled && document.visibilityState === 'visible') { + await this.requestWakeLock(); + } + }; +} diff --git a/frontend/src/assets/embedpdf-frame.html b/frontend/src/assets/embedpdf-frame.html new file mode 100644 index 000000000..7238027ce --- /dev/null +++ b/frontend/src/assets/embedpdf-frame.html @@ -0,0 +1,255 @@ + + + + + + + + +
    + + + diff --git a/frontend/src/i18n/en/common.json b/frontend/src/i18n/en/common.json index aeb893577..1ca397651 100644 --- a/frontend/src/i18n/en/common.json +++ b/frontend/src/i18n/en/common.json @@ -22,5 +22,7 @@ "dismiss": "Dismiss", "discard": "Discard", "review": "Review", - "libraryPathInaccessible": "Library path inaccessible" + "libraryPathInaccessible": "Library path inaccessible", + "on": "On", + "off": "Off" } diff --git a/frontend/src/i18n/en/reader-cbx.json b/frontend/src/i18n/en/reader-cbx.json index 188ca70d2..3ac429acf 100644 --- a/frontend/src/i18n/en/reader-cbx.json +++ b/frontend/src/i18n/en/reader-cbx.json @@ -110,7 +110,11 @@ "gray": "Gray", "white": "White", "magnifierLensSize": "Lens Size", - "magnifierZoom": "Magnification" + "magnifierZoom": "Magnification", + "brightness": "Brightness", + "emulateBook": "Emulate Book", + "clickToPaginate": "Click to Paginate", + "autoCloseMenu": "Auto Close Menu" }, "sidebar": { "contentTab": "Content", diff --git a/frontend/src/i18n/en/reader-ebook.json b/frontend/src/i18n/en/reader-ebook.json index 4ce02fa4f..3b629e31f 100644 --- a/frontend/src/i18n/en/reader-ebook.json +++ b/frontend/src/i18n/en/reader-ebook.json @@ -152,6 +152,7 @@ "notesShortcut": "Notes", "display": "Display", "toggleFullscreen": "Toggle fullscreen", + "toggleImmersive": "Toggle immersive mode", "exitFullscreenCloseDialogs": "Exit fullscreen / Close dialogs", "other": "Other", "showHelpDialog": "Show this help dialog", diff --git a/frontend/src/i18n/en/reader-pdf.json b/frontend/src/i18n/en/reader-pdf.json index c56345f7f..b9465dfde 100644 --- a/frontend/src/i18n/en/reader-pdf.json +++ b/frontend/src/i18n/en/reader-pdf.json @@ -2,7 +2,20 @@ "toolbar": { "switchToLightMode": "Switch to Light Mode", "switchToDarkMode": "Switch to Dark Mode", - "closePdfReader": "Close PDF Reader" + "closePdfReader": "Close PDF Reader", + "rotatePage": "Rotate Page", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit Fullscreen" + }, + "footer": { + "firstPage": "First Page", + "previousPage": "Previous Page", + "nextPage": "Next Page", + "lastPage": "Last Page", + "of": "of", + "pageSlider": "Page Slider", + "pagePlaceholder": "Page", + "go": "Go" }, "toast": { "failedToLoadBook": "Failed to load the book" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a3111d5b0..292a54882 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1026,6 +1026,625 @@ __metadata: languageName: node linkType: hard +"@embedpdf/core@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/core@npm:2.11.1" + dependencies: + "@embedpdf/engines": "npm:2.11.1" + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/75dd374e25fb8f70d61df545002945f02bd45761a783ff1d9a32aaf4d549699e749e58a7b95a2095c3c1210fecd280d54ba4e9a8a825df8905a46bdd6ec251c9 + languageName: node + linkType: hard + +"@embedpdf/engines@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/engines@npm:2.11.1" + dependencies: + "@embedpdf/fonts-arabic": "npm:1.0.0" + "@embedpdf/fonts-hebrew": "npm:1.0.0" + "@embedpdf/fonts-jp": "npm:1.0.0" + "@embedpdf/fonts-kr": "npm:1.0.0" + "@embedpdf/fonts-latin": "npm:1.0.0" + "@embedpdf/fonts-sc": "npm:1.0.0" + "@embedpdf/fonts-tc": "npm:1.0.0" + "@embedpdf/models": "npm:2.11.1" + "@embedpdf/pdfium": "npm:2.11.1" + peerDependencies: + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/1e010626669dcc8ea0d9a4b8fe397a1a39e7c393706d4c363f42464ef5eb2098027011459b593a79e6cc5504201f49ae12b81ede1bc08d4f39a5004cb0d082e8 + languageName: node + linkType: hard + +"@embedpdf/fonts-arabic@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-arabic@npm:1.0.0" + checksum: 10c0/324d724b9e8d477d857c8673c5694a550f6dad90c4cf19f8f0498ec3dcd25da09a89ffd38a8d85c9e20ed59df36e0c4802df02f8ad427b6d8091a7ba68cd469f + languageName: node + linkType: hard + +"@embedpdf/fonts-hebrew@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-hebrew@npm:1.0.0" + checksum: 10c0/d67988063fc082adc246a74a0aae1e99fbfaa9d8425911128ca123e047e38323af4bfacfb1cab25277f7233701cf0e69bdc2477d8f213396d55e0d3628664530 + languageName: node + linkType: hard + +"@embedpdf/fonts-jp@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-jp@npm:1.0.0" + checksum: 10c0/0a0d989a085ec6aa3c6c370f86ed461e0951cc22f4fc4565ae3daff75c4fbf7c2f0eeb6d6902a63cea580aa6f95bc5457db2d19d3caaeb2070a436950449aa54 + languageName: node + linkType: hard + +"@embedpdf/fonts-kr@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-kr@npm:1.0.0" + checksum: 10c0/7750148272e088d9961b383188ec07e02ced6209a1274441c149690fc4d28e7591cceaa26e7e68991e4a8468ce6da7b916d61c0b3bf5e4fb8c24c58b66ec0be3 + languageName: node + linkType: hard + +"@embedpdf/fonts-latin@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-latin@npm:1.0.0" + checksum: 10c0/a2c862d288a99a0f0134aa4781ce81bce00fd0b039e81401e2efaea0affc67a2f7b456252598a16953a5ba04ce11dadf9f95e4b4ff5bb9de1a32a41dd9dceffd + languageName: node + linkType: hard + +"@embedpdf/fonts-sc@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-sc@npm:1.0.0" + checksum: 10c0/2a5126390f9947656162601e3f0b3163eee5c2d362f0115d6d54137835265a3a672f98e6399e0c8efbe506546216f692d870d71e3803c0f2a18b631fd262cd34 + languageName: node + linkType: hard + +"@embedpdf/fonts-tc@npm:1.0.0": + version: 1.0.0 + resolution: "@embedpdf/fonts-tc@npm:1.0.0" + checksum: 10c0/f0ac603b81de074b1ec41c16ac3daf7dc6f7ae02659a9ee08006ab95c0fc4ff10338bc73ccf8cfbf9a357bdd158e08baa54a61ee8c87b5909154454ccddfb41c + languageName: node + linkType: hard + +"@embedpdf/models@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/models@npm:2.11.1" + checksum: 10c0/0c8ecde3acd64ccba91db2ea74ed50b4d499a28347ef5f6df655d700f016dffe35940379468d610087c92d5754fb9b1ec5fb7ba588e13b316a8035ac1c051de1 + languageName: node + linkType: hard + +"@embedpdf/pdfium@npm:2.11.1, @embedpdf/pdfium@npm:^2.11.1": + version: 2.11.1 + resolution: "@embedpdf/pdfium@npm:2.11.1" + checksum: 10c0/ee2521756e0c2bb480baf956a287c0281810962e55b012e5eb39e050f17de3e9f88e2a9f5b341a3dd9756684ff0376cfbfca98cb0a1f5df446e9905c7e06a68c + languageName: node + linkType: hard + +"@embedpdf/plugin-annotation@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-annotation@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + "@embedpdf/utils": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-history": 2.11.1 + "@embedpdf/plugin-interaction-manager": 2.11.1 + "@embedpdf/plugin-scroll": 2.11.1 + "@embedpdf/plugin-selection": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/6a1e996ea65002268f0d4b75c08f53a9cf744f896b464ad0d23333fb395e642f48b69ebe85e6a2f3291425b01a14093c6d70b207e88a724286cca334041f1672 + languageName: node + linkType: hard + +"@embedpdf/plugin-attachment@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-attachment@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/ef28a71ec8cad9d0b05e31b09423b8c87d00ad40e4caafa7ddd68c122071b2a156d5b25836d8eaef35df4d7f6463cb72df96a464a80ce1c0d7a8fb3e59ea8e60 + languageName: node + linkType: hard + +"@embedpdf/plugin-bookmark@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-bookmark@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/287c89fcd643dbd138ff3ad3d31d9d873d2cfa95f9951b31a2417115f5e3ce9a224118a234c2cbf837160da5d5e431d7b6aa25c0f8ab753d1aeb26a510b7f2b9 + languageName: node + linkType: hard + +"@embedpdf/plugin-capture@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-capture@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-interaction-manager": 2.11.1 + "@embedpdf/plugin-render": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/ca44ccaedbeee593ee2206dc6f8937dc91ff6e7ee150dbf8c884cb26d84a7562febe5a5975d81c8b2324f88be1caa48daa2f038f362b570878796376e5995f34 + languageName: node + linkType: hard + +"@embedpdf/plugin-commands@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-commands@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/11cadacb9ad9c777b07bdfbdfa5bd24b22e8299a548cdb9f550c4c3797ca44d9c5498704dc583a572a8d21c127b0248cb8979dc2005cddc079cc321f113e3b50 + languageName: node + linkType: hard + +"@embedpdf/plugin-document-manager@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-document-manager@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/a8cd9670c4419186cd5cf58906797361038fa46d980f7f85954b5f68ab1fe9ed7e78681f86c14666a637377b3b70beacdded18f339a58946618e77c3895dc6f4 + languageName: node + linkType: hard + +"@embedpdf/plugin-export@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-export@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/9080f9792b39d097a0975f7d07e1eed9816b842cffa511c2bf2c0acea4fec21bb58bcb4ab47c236abcfc7f8ec27c6f02f7050783dc472a80a682d32baa4115e6 + languageName: node + linkType: hard + +"@embedpdf/plugin-form@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-form@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + "@embedpdf/utils": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-annotation": 2.11.1 + "@embedpdf/plugin-history": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/f286c5350317705cb4176ebe978315a900c66a3a715b41fa513f09b3901573c412f6ac3ad1545e6afecdd8b76d8efc28d2dc0f3bd68c45c7c78a129d3e97318a + languageName: node + linkType: hard + +"@embedpdf/plugin-fullscreen@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-fullscreen@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/9d75152666569ec9829409a8fc3ac1fdd6385b546c70261a7a5fea86303fd0f0d4c0f7cbae64f4c9acb6bb55578aeee7a788e05369076e46b7f2fbcc77413cc8 + languageName: node + linkType: hard + +"@embedpdf/plugin-history@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-history@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/078457ac898fc05a07db44174064f82c27540dc5293926f617207e904ebc1e8b1b0b122bdcc674996c33f287f6cc2420b43a9bdc7e73dd841bec277a6290e666 + languageName: node + linkType: hard + +"@embedpdf/plugin-i18n@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-i18n@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/5437b687f94a5da79aa39208f706b6822d587f44ba2b7fe5bff3b02c0532f7e74bd7d6e9864dd00b5fc35b75494daf7114900e9c665eed85e50d6fd87a18a662 + languageName: node + linkType: hard + +"@embedpdf/plugin-interaction-manager@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-interaction-manager@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/8426bd96892325f573feb7a6d74c2b4d5fe8e1c926e8010549cdf020c7fb3a9c5b33221c8b013e61388be56dd582d78024a5deed45dbf3e859c4dbf4eccf7684 + languageName: node + linkType: hard + +"@embedpdf/plugin-pan@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-pan@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-interaction-manager": 2.11.1 + "@embedpdf/plugin-viewport": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/c32070588d78262ee32e1d16a8304ecb4dce3580ccab289332c02c04eefb56d382fd6e34f729a7bf3d08b26a56f995f4d37c57b9176b2032178c8f05d0086947 + languageName: node + linkType: hard + +"@embedpdf/plugin-print@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-print@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=18.0.0" + react-dom: ">=18.0.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/616900052da305b6f72e08832fe76950ebcf10d7886e87a342abbfc641d5687abd38f97c88681205562c2768f8d2e418d074aa7f7b2ba6fb86164d240c479624 + languageName: node + linkType: hard + +"@embedpdf/plugin-redaction@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-redaction@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + "@embedpdf/utils": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-annotation": 2.11.1 + "@embedpdf/plugin-history": 2.11.1 + "@embedpdf/plugin-interaction-manager": 2.11.1 + "@embedpdf/plugin-selection": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/7da6c3b42ee40ce21a242f847493108e56fce6d91e24f902a00b14b3adfbeef439bfd546de31a5bdc4137ddf130f1f1bba01078893bde5393e42036fddad88dd + languageName: node + linkType: hard + +"@embedpdf/plugin-render@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-render@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/1387451b5bd786d00a6530072488ae7b3938bd15e2662ddd4e98edb47d1bb51b052f73d2fc67a22c9518d0c3641292f1ac89cfa4d6ffcd53132957ac7fb1ea99 + languageName: node + linkType: hard + +"@embedpdf/plugin-rotate@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-rotate@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/6cb3067ada41d73172ebce1dbc3d4b8cae82c1c8864d3ce02457385cf781cd90dfc70861f20861092e52155b6cb11fafd0c94e4b9548943b2a73fcc61e6456be + languageName: node + linkType: hard + +"@embedpdf/plugin-scroll@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-scroll@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-viewport": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/b6500e71deb6e400fa5efe0f1a09270e82ff4280535886136e7bd2df41fbfd326f5ae4d018bbcfe76e5aa09b6fbde72c994aaa9fa0605d94e00f78eb7ab6e93b + languageName: node + linkType: hard + +"@embedpdf/plugin-search@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-search@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/ffa9b6f07d9b4868fa7a79b7ea05c790dc4b6ae6461c256c6420c6690260be3d14b7252378ae8593b883764731eb7f360b615b0986c041c423e110a152100160 + languageName: node + linkType: hard + +"@embedpdf/plugin-selection@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-selection@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + "@embedpdf/utils": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-interaction-manager": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/144c46e1d50dbb07d0136548955d37ff33a1f60099529bb59ff4cbc1b0ad6223ce67b6003a5ff263c86cc67591d4b6f0bb27011fd95ffe8574f337e531415e30 + languageName: node + linkType: hard + +"@embedpdf/plugin-spread@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-spread@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/b4777a94ca1679d7b8b4c996ef885d9870150ae9106eb2dcf40c5dcbf45781a5e7282fb3014351e7707f12d9ec1612f3e9d4806c67b585437f271ad3e334f090 + languageName: node + linkType: hard + +"@embedpdf/plugin-stamp@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-stamp@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-annotation": 2.11.1 + "@embedpdf/plugin-i18n": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/8780c952d70ea2800ac620288188d8d1180081542004d7ef1b60dda030f0b4a521672310ad45f8d725958871b701263d69fb4fbcbd270b1ac95a0f0739d48000 + languageName: node + linkType: hard + +"@embedpdf/plugin-thumbnail@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-thumbnail@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-render": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/c02ec3032221417416c2776abfbaa96a95c76ce62f3a6865b60aefa46959b2a7708bb3547cfcecf8a6e43130ac274d490fda93205f598b147afadb04b1d3ba06 + languageName: node + linkType: hard + +"@embedpdf/plugin-tiling@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-tiling@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-render": 2.11.1 + "@embedpdf/plugin-scroll": 2.11.1 + "@embedpdf/plugin-viewport": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/85f43a3ee0a73ca7959595ee1c2f9d9e6af6152e8fde84984a9cff8cf2de1d5e7954b2817e5573f2802efd00818f36a80a16d5ad6b2112866bed3baf6afc30de + languageName: node + linkType: hard + +"@embedpdf/plugin-ui@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-ui@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-render": 2.11.1 + "@embedpdf/plugin-scroll": 2.11.1 + "@embedpdf/plugin-viewport": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/38431f2edf9e11129236bc4557528caf8b2af9dfd33dc6ce01e6438c6e29666558539c55ba06c0c6199425b6cdbc1e55d021a0e9d6a4b47e4a6169aeac9a6d3a + languageName: node + linkType: hard + +"@embedpdf/plugin-viewport@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-viewport@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/61116fe5039386eea789cbfcbe8ca73ab62edf732e22cfb85cacc264522bfe0cd37bb57f32fca99d2a434ff6affb20e29e6dce9f0c7616edecbb59d400a97a2d + languageName: node + linkType: hard + +"@embedpdf/plugin-zoom@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/plugin-zoom@npm:2.11.1" + dependencies: + "@embedpdf/models": "npm:2.11.1" + peerDependencies: + "@embedpdf/core": 2.11.1 + "@embedpdf/plugin-scroll": 2.11.1 + "@embedpdf/plugin-viewport": 2.11.1 + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/5cc8d4d5eab5aed70ca7ef2446cb0f47626268d85eef58ecfea8ac6b0bc08fb3c3f51cda3a536bb8b0578016762687f4bd98b424e4bb58fe83faa04ab3f488cd + languageName: node + linkType: hard + +"@embedpdf/snippet@npm:^2.11.1": + version: 2.11.1 + resolution: "@embedpdf/snippet@npm:2.11.1" + dependencies: + "@embedpdf/core": "npm:2.11.1" + "@embedpdf/engines": "npm:2.11.1" + "@embedpdf/models": "npm:2.11.1" + "@embedpdf/pdfium": "npm:2.11.1" + "@embedpdf/plugin-annotation": "npm:2.11.1" + "@embedpdf/plugin-attachment": "npm:2.11.1" + "@embedpdf/plugin-bookmark": "npm:2.11.1" + "@embedpdf/plugin-capture": "npm:2.11.1" + "@embedpdf/plugin-commands": "npm:2.11.1" + "@embedpdf/plugin-document-manager": "npm:2.11.1" + "@embedpdf/plugin-export": "npm:2.11.1" + "@embedpdf/plugin-form": "npm:2.11.1" + "@embedpdf/plugin-fullscreen": "npm:2.11.1" + "@embedpdf/plugin-history": "npm:2.11.1" + "@embedpdf/plugin-i18n": "npm:2.11.1" + "@embedpdf/plugin-interaction-manager": "npm:2.11.1" + "@embedpdf/plugin-pan": "npm:2.11.1" + "@embedpdf/plugin-print": "npm:2.11.1" + "@embedpdf/plugin-redaction": "npm:2.11.1" + "@embedpdf/plugin-render": "npm:2.11.1" + "@embedpdf/plugin-rotate": "npm:2.11.1" + "@embedpdf/plugin-scroll": "npm:2.11.1" + "@embedpdf/plugin-search": "npm:2.11.1" + "@embedpdf/plugin-selection": "npm:2.11.1" + "@embedpdf/plugin-spread": "npm:2.11.1" + "@embedpdf/plugin-stamp": "npm:2.11.1" + "@embedpdf/plugin-thumbnail": "npm:2.11.1" + "@embedpdf/plugin-tiling": "npm:2.11.1" + "@embedpdf/plugin-ui": "npm:2.11.1" + "@embedpdf/plugin-viewport": "npm:2.11.1" + "@embedpdf/plugin-zoom": "npm:2.11.1" + preact: "npm:^10.17.0" + tailwind-merge: "npm:^3.4.0" + checksum: 10c0/dc987e3e42a0b90a41ec71e0b80ef095e823c635d5f5ceac230e6410ed3070f5e311eca4e374ab25ea5ba2f150cee784b7a8ebfd552e3e9556156e75e53e9b13 + languageName: node + linkType: hard + +"@embedpdf/utils@npm:2.11.1": + version: 2.11.1 + resolution: "@embedpdf/utils@npm:2.11.1" + peerDependencies: + preact: ^10.26.4 + react: ">=16.8.0" + react-dom: ">=16.8.0" + svelte: ">=5 <6" + vue: ">=3.2.0" + checksum: 10c0/1ef1908070a6dfbf188dc43d4a4f29851a7c54980e1eb76959f4213809aaf7d45c65853e1813bc6dfa160a584b70db6913dbfb4ea1683bc424d5b5df6fdb49b6 + languageName: node + linkType: hard + "@emnapi/core@npm:^1.7.1": version: 1.9.1 resolution: "@emnapi/core@npm:1.9.1" @@ -5430,6 +6049,8 @@ __metadata: "@angular/platform-browser-dynamic": "npm:^21.2.6" "@angular/router": "npm:^21.2.6" "@angular/service-worker": "npm:^21.2.6" + "@embedpdf/pdfium": "npm:^2.11.1" + "@embedpdf/snippet": "npm:^2.11.1" "@eslint/js": "npm:^10.0.1" "@iharbeck/ngx-virtual-scroller": "npm:^20.0.0" "@jsverse/transloco": "npm:^8.2.1" @@ -7333,6 +7954,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.17.0": + version: 10.29.0 + resolution: "preact@npm:10.29.0" + checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -8385,6 +9013,13 @@ __metadata: languageName: node linkType: hard +"tailwind-merge@npm:^3.4.0": + version: 3.5.0 + resolution: "tailwind-merge@npm:3.5.0" + checksum: 10c0/4dc588f5b5296ba3f38e1ebb41f02b6d24a8c5bb45e44b33748c118fb4b5767dd0efc464431ca3e75404056b618b5f67bec3708158baa65fed8a2fc9201e0c53 + languageName: node + linkType: hard + "tar@npm:^7.4.3, tar@npm:^7.5.4": version: 7.5.13 resolution: "tar@npm:7.5.13"