mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(cbx-reader): add fullscreen, slideshow, RTL, long strip mode and keyboard shortcuts (#2632)
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
<div class="comic-reader-container" tabindex="0">
|
||||
<div class="comic-reader-container" tabindex="0"
|
||||
[class.rtl-reading]="readingDirection === CbxReadingDirection.RTL"
|
||||
[class.fullscreen-mode]="isFullscreen"
|
||||
[class.slideshow-active]="isSlideshowActive">
|
||||
<app-cbx-header [isCurrentPageBookmarked]="isCurrentPageBookmarked" [currentPageHasNotes]="currentPageHasNotes"></app-cbx-header>
|
||||
|
||||
<app-cbx-sidebar></app-cbx-sidebar>
|
||||
@@ -23,9 +26,22 @@
|
||||
></app-cbx-note-dialog>
|
||||
}
|
||||
|
||||
@if (showShortcutsHelp) {
|
||||
<app-cbx-shortcuts-help
|
||||
(close)="onShortcutsHelpClose()"
|
||||
></app-cbx-shortcuts-help>
|
||||
}
|
||||
|
||||
@if (isSlideshowActive) {
|
||||
<div class="slideshow-indicator">
|
||||
<span class="slideshow-badge">Slideshow</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="image-container"
|
||||
[class.two-page-view]="isTwoPageView && scrollMode === CbxScrollMode.PAGINATED"
|
||||
[class.infinite-scroll]="scrollMode === CbxScrollMode.INFINITE"
|
||||
[class.long-strip]="scrollMode === CbxScrollMode.LONG_STRIP"
|
||||
[class.fit-actual-size]="fitMode === CbxFitMode.ACTUAL_SIZE"
|
||||
[class.fit-page]="fitMode === CbxFitMode.FIT_PAGE"
|
||||
[class.fit-width]="fitMode === CbxFitMode.FIT_WIDTH"
|
||||
@@ -38,7 +54,7 @@
|
||||
@if (!isLoading) {
|
||||
@if (pages.length > 0) {
|
||||
@if (scrollMode === CbxScrollMode.PAGINATED) {
|
||||
<div class="pages-wrapper" (click)="onImageClick()">
|
||||
<div class="pages-wrapper" (click)="onImageClick()" (dblclick)="onImageDoubleClick()">
|
||||
@if (isPageTransitioning && previousImageUrls.length > 0) {
|
||||
<div class="previous-page-layer">
|
||||
@for (url of previousImageUrls; track url) {
|
||||
@@ -47,8 +63,8 @@
|
||||
</div>
|
||||
}
|
||||
<div class="current-page-layer" [class.fade-in]="isPageTransitioning && imagesLoaded">
|
||||
@for (url of currentImageUrls; track url) {
|
||||
<img [src]="url" alt="Page Image" class="page-image" (load)="onImageLoad()"/>
|
||||
@for (url of currentImageUrls; track url; let i = $index) {
|
||||
<img [src]="url" alt="Page Image" class="page-image" (load)="onPageImageLoad($event, currentPage + i)"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,9 +78,9 @@
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="infinite-scroll-wrapper">
|
||||
<div class="infinite-scroll-wrapper" [class.long-strip-wrapper]="scrollMode === CbxScrollMode.LONG_STRIP">
|
||||
@for (url of infiniteScrollImageUrls; track url; let i = $index) {
|
||||
<img [src]="url" alt="Page {{ infiniteScrollPages[i] + 1 }}" class="page-image" (click)="onImageClick()"/>
|
||||
<img [src]="url" alt="Page {{ infiniteScrollPages[i] + 1 }}" class="page-image" (click)="onImageClick()" (dblclick)="onImageDoubleClick()" (load)="onPageImageLoad($event, infiniteScrollPages[i])"/>
|
||||
}
|
||||
@if (isLoadingMore) {
|
||||
<div class="loading-more">
|
||||
|
||||
@@ -17,6 +17,46 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// RTL reading mode - reverse page order in two-page view
|
||||
&.rtl-reading {
|
||||
.two-page-view {
|
||||
.pages-wrapper,
|
||||
.previous-page-layer,
|
||||
.current-page-layer {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slideshow indicator
|
||||
.slideshow-indicator {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 11;
|
||||
pointer-events: none;
|
||||
|
||||
.slideshow-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(74, 144, 226, 0.9);
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.bookmark-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -358,6 +398,74 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Long Strip / Webtoon mode - no gaps between pages
|
||||
&.long-strip {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&.fit-width .page-image {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&.fit-page .page-image,
|
||||
&.fit-auto .page-image {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.no-pages {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {filter, first, map, switchMap, takeUntil, timeout} 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, CbxPageSpread, CbxPageViewMode, CbxScrollMode, PdfBackgroundColor, PdfFitMode, PdfPageSpread, PdfPageViewMode, PdfScrollMode, UserService} from '../../settings/user-management/user.service';
|
||||
import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrollMode, CbxReadingDirection, CbxSlideshowInterval, PdfBackgroundColor, PdfFitMode, PdfPageSpread, PdfPageViewMode, PdfScrollMode, UserService} from '../../settings/user-management/user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Book, BookSetting, BookType} from '../../book/model/book.model';
|
||||
import {BookState} from '../../book/model/state/book-state.model';
|
||||
@@ -25,6 +25,7 @@ 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';
|
||||
|
||||
|
||||
@@ -39,7 +40,8 @@ import {BookNoteV2} from '../../../shared/service/book-note-v2.service';
|
||||
CbxSidebarComponent,
|
||||
CbxFooterComponent,
|
||||
CbxQuickSettingsComponent,
|
||||
CbxNoteDialogComponent
|
||||
CbxNoteDialogComponent,
|
||||
CbxShortcutsHelpComponent
|
||||
],
|
||||
providers: [
|
||||
CbxHeaderService,
|
||||
@@ -92,6 +94,30 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
noteDialogData: CbxNoteDialogData | null = null;
|
||||
private editingNote: BookNoteV2 | null = null;
|
||||
|
||||
// Fullscreen state
|
||||
isFullscreen = false;
|
||||
|
||||
// Reading direction
|
||||
readingDirection: CbxReadingDirection = CbxReadingDirection.LTR;
|
||||
|
||||
// Slideshow state
|
||||
isSlideshowActive = false;
|
||||
slideshowInterval: CbxSlideshowInterval = CbxSlideshowInterval.FIVE_SECONDS;
|
||||
private slideshowTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Double-tap zoom
|
||||
private lastTapTime = 0;
|
||||
private originalFitMode: CbxFitMode | PdfFitMode | null = null;
|
||||
|
||||
// Shortcuts help dialog
|
||||
showShortcutsHelp = false;
|
||||
|
||||
// Double page detection
|
||||
private pageDimensionsCache = new Map<number, {width: number, height: number}>();
|
||||
|
||||
protected readonly CbxReadingDirection = CbxReadingDirection;
|
||||
protected readonly CbxSlideshowInterval = CbxSlideshowInterval;
|
||||
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private cbxReaderService = inject(CbxReaderService);
|
||||
@@ -276,6 +302,24 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
.subscribe(() => {
|
||||
this.openNoteDialog();
|
||||
});
|
||||
|
||||
this.headerService.toggleFullscreen$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.toggleFullscreen();
|
||||
});
|
||||
|
||||
this.headerService.toggleSlideshow$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.toggleSlideshow();
|
||||
});
|
||||
|
||||
this.headerService.showShortcutsHelp$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.showShortcutsHelp = true;
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToSidebarEvents(): void {
|
||||
@@ -364,6 +408,14 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
this.quickSettingsService.backgroundColorChange$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(color => this.onBackgroundColorChange(color));
|
||||
|
||||
this.quickSettingsService.readingDirectionChange$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(direction => this.onReadingDirectionChange(direction));
|
||||
|
||||
this.quickSettingsService.slideshowIntervalChange$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(interval => this.onSlideshowIntervalChange(interval));
|
||||
}
|
||||
|
||||
private updateServiceStates(): void {
|
||||
@@ -381,7 +433,14 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
scrollMode: this.scrollMode,
|
||||
pageViewMode: this.pageViewMode,
|
||||
pageSpread: this.pageSpread,
|
||||
backgroundColor: this.backgroundColor
|
||||
backgroundColor: this.backgroundColor,
|
||||
readingDirection: this.readingDirection,
|
||||
slideshowInterval: this.slideshowInterval
|
||||
});
|
||||
|
||||
this.headerService.updateState({
|
||||
isFullscreen: this.isFullscreen,
|
||||
isSlideshowActive: this.isSlideshowActive
|
||||
});
|
||||
|
||||
this.sidebarService.setCurrentPage(this.currentPage + 1);
|
||||
@@ -411,7 +470,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private preloadAdjacentPages(): void {
|
||||
if (!this.pages.length || this.scrollMode === CbxScrollMode.INFINITE) return;
|
||||
if (!this.pages.length || this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) return;
|
||||
|
||||
const pagesToPreload: number[] = [];
|
||||
|
||||
@@ -460,7 +519,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private transitionToNewPage(): void {
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE) {
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) {
|
||||
this.updateCurrentImageUrls();
|
||||
return;
|
||||
}
|
||||
@@ -556,11 +615,23 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
const previousPage = this.currentPage;
|
||||
this.pauseSlideshowOnInteraction();
|
||||
this.advancePage(1);
|
||||
}
|
||||
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE) {
|
||||
if (this.currentPage < this.pages.length - 1) {
|
||||
this.currentPage++;
|
||||
previousPage() {
|
||||
this.pauseSlideshowOnInteraction();
|
||||
this.advancePage(-1);
|
||||
}
|
||||
|
||||
private advancePage(direction: 1 | -1): void {
|
||||
const previousPage = this.currentPage;
|
||||
const step = this.getPageStep();
|
||||
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) {
|
||||
const newPage = this.currentPage + direction;
|
||||
if (newPage >= 0 && newPage < this.pages.length) {
|
||||
this.currentPage = newPage;
|
||||
this.scrollToPage(this.currentPage);
|
||||
this.updateProgress();
|
||||
this.updateSessionProgress();
|
||||
@@ -569,43 +640,26 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction > 0) {
|
||||
// Forward navigation
|
||||
if (this.isTwoPageView) {
|
||||
if (this.currentPage + 2 < this.pages.length) {
|
||||
this.currentPage += 2;
|
||||
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) {
|
||||
this.currentPage++;
|
||||
}
|
||||
|
||||
if (this.currentPage !== previousPage) {
|
||||
this.transitionToNewPage();
|
||||
this.updateProgress();
|
||||
this.updateSessionProgress();
|
||||
this.updateFooterPage();
|
||||
}
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
const previousPage = this.currentPage;
|
||||
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE) {
|
||||
if (this.currentPage > 0) {
|
||||
this.currentPage--;
|
||||
this.scrollToPage(this.currentPage);
|
||||
this.updateProgress();
|
||||
this.updateSessionProgress();
|
||||
this.updateFooterPage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
// Backward navigation
|
||||
if (this.isTwoPageView) {
|
||||
this.currentPage = Math.max(0, this.currentPage - 2);
|
||||
this.currentPage = Math.max(0, this.currentPage - step);
|
||||
} else {
|
||||
this.currentPage = Math.max(0, this.currentPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentPage !== previousPage) {
|
||||
this.transitionToNewPage();
|
||||
@@ -613,6 +667,15 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
this.updateSessionProgress();
|
||||
this.updateFooterPage();
|
||||
}
|
||||
|
||||
// Stop slideshow at last page
|
||||
if (this.isSlideshowActive && this.currentPage >= this.pages.length - 1) {
|
||||
this.stopSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
private getPageStep(): number {
|
||||
return this.isTwoPageView ? 2 : 1;
|
||||
}
|
||||
|
||||
private alignCurrentPageToParity() {
|
||||
@@ -646,7 +709,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
this.quickSettingsService.setScrollMode(mode);
|
||||
this.updateViewerSetting();
|
||||
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE) {
|
||||
if (this.scrollMode === CbxScrollMode.INFINITE || this.scrollMode === CbxScrollMode.LONG_STRIP) {
|
||||
this.initializeInfiniteScroll();
|
||||
setTimeout(() => this.scrollToPage(this.currentPage), 100);
|
||||
} else {
|
||||
@@ -690,7 +753,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onScroll(event: Event): void {
|
||||
if (this.scrollMode !== CbxScrollMode.INFINITE || this.isLoadingMore) return;
|
||||
if ((this.scrollMode !== CbxScrollMode.INFINITE && this.scrollMode !== CbxScrollMode.LONG_STRIP) || this.isLoadingMore) return;
|
||||
|
||||
const container = event.target as HTMLElement;
|
||||
const scrollPosition = container.scrollTop + container.clientHeight;
|
||||
@@ -864,8 +927,80 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight') this.nextPage();
|
||||
else if (event.key === 'ArrowLeft') this.previousPage();
|
||||
// Ignore if typing in input/textarea
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRtl = this.readingDirection === CbxReadingDirection.RTL;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
isRtl ? this.previousPage() : this.nextPage();
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
isRtl ? this.nextPage() : this.previousPage();
|
||||
event.preventDefault();
|
||||
break;
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
event.shiftKey ? this.previousPage() : this.nextPage();
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
this.firstPage();
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
this.lastPage();
|
||||
break;
|
||||
case 'PageUp':
|
||||
event.preventDefault();
|
||||
this.previousPage();
|
||||
break;
|
||||
case 'PageDown':
|
||||
event.preventDefault();
|
||||
this.nextPage();
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
event.preventDefault();
|
||||
this.toggleFullscreen();
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
event.preventDefault();
|
||||
this.toggleReadingDirection();
|
||||
break;
|
||||
case 'p':
|
||||
case 'P':
|
||||
event.preventDefault();
|
||||
this.toggleSlideshow();
|
||||
break;
|
||||
case '?':
|
||||
event.preventDefault();
|
||||
this.showShortcutsHelp = true;
|
||||
break;
|
||||
case 'Escape':
|
||||
if (this.showShortcutsHelp) {
|
||||
this.showShortcutsHelp = false;
|
||||
} else if (this.showNoteDialog) {
|
||||
this.showNoteDialog = false;
|
||||
} else if (this.showQuickSettings) {
|
||||
this.quickSettingsService.close();
|
||||
} else if (this.isFullscreen) {
|
||||
this.exitFullscreen();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
this.isFullscreen = !!document.fullscreenElement;
|
||||
this.headerService.updateState({isFullscreen: this.isFullscreen, isSlideshowActive: this.isSlideshowActive});
|
||||
}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
@@ -897,7 +1032,12 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
private handleSwipeGesture() {
|
||||
const delta = this.touchEndX - this.touchStartX;
|
||||
if (Math.abs(delta) >= 50) delta < 0 ? this.nextPage() : this.previousPage();
|
||||
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;
|
||||
shouldGoNext ? this.nextPage() : this.previousPage();
|
||||
}
|
||||
}
|
||||
|
||||
private enforcePortraitSinglePageView() {
|
||||
@@ -996,7 +1136,138 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
// Fullscreen methods
|
||||
toggleFullscreen(): void {
|
||||
if (this.isFullscreen) {
|
||||
this.exitFullscreen();
|
||||
} else {
|
||||
this.enterFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
private enterFullscreen(): void {
|
||||
const elem = document.documentElement;
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private exitFullscreen(): void {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Reading direction methods
|
||||
toggleReadingDirection(): void {
|
||||
const newDirection = this.readingDirection === CbxReadingDirection.LTR
|
||||
? CbxReadingDirection.RTL
|
||||
: CbxReadingDirection.LTR;
|
||||
this.onReadingDirectionChange(newDirection);
|
||||
}
|
||||
|
||||
onReadingDirectionChange(direction: CbxReadingDirection): void {
|
||||
this.readingDirection = direction;
|
||||
this.quickSettingsService.setReadingDirection(direction);
|
||||
}
|
||||
|
||||
// Slideshow methods
|
||||
toggleSlideshow(): void {
|
||||
if (this.isSlideshowActive) {
|
||||
this.stopSlideshow();
|
||||
} else {
|
||||
this.startSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
startSlideshow(): void {
|
||||
if (this.currentPage >= this.pages.length - 1) return;
|
||||
|
||||
this.isSlideshowActive = true;
|
||||
this.headerService.updateState({isFullscreen: this.isFullscreen, isSlideshowActive: true});
|
||||
|
||||
this.slideshowTimer = setInterval(() => {
|
||||
if (this.currentPage < this.pages.length - 1) {
|
||||
this.advancePage(1);
|
||||
} else {
|
||||
this.stopSlideshow();
|
||||
}
|
||||
}, this.slideshowInterval);
|
||||
}
|
||||
|
||||
stopSlideshow(): void {
|
||||
if (this.slideshowTimer) {
|
||||
clearInterval(this.slideshowTimer);
|
||||
this.slideshowTimer = null;
|
||||
}
|
||||
this.isSlideshowActive = false;
|
||||
this.headerService.updateState({isFullscreen: this.isFullscreen, isSlideshowActive: false});
|
||||
}
|
||||
|
||||
private pauseSlideshowOnInteraction(): void {
|
||||
if (this.isSlideshowActive) {
|
||||
this.stopSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
onSlideshowIntervalChange(interval: CbxSlideshowInterval): void {
|
||||
this.slideshowInterval = interval;
|
||||
this.quickSettingsService.setSlideshowInterval(interval);
|
||||
|
||||
// Restart slideshow with new interval if active
|
||||
if (this.isSlideshowActive) {
|
||||
this.stopSlideshow();
|
||||
this.startSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
// Double-tap zoom
|
||||
onImageDoubleClick(): void {
|
||||
if (this.originalFitMode === null) {
|
||||
// Store current fit mode and switch to actual size
|
||||
this.originalFitMode = this.fitMode;
|
||||
this.onFitModeChange(CbxFitMode.ACTUAL_SIZE);
|
||||
} else {
|
||||
// Restore original fit mode
|
||||
this.onFitModeChange(this.originalFitMode as CbxFitMode);
|
||||
this.originalFitMode = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Double page detection
|
||||
onPageImageLoad(event: Event, pageIndex: number): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
this.pageDimensionsCache.set(pageIndex, {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
});
|
||||
}
|
||||
this.imagesLoaded = true;
|
||||
}
|
||||
|
||||
isSpreadPage(pageIndex: number): boolean {
|
||||
const dims = this.pageDimensionsCache.get(pageIndex);
|
||||
if (!dims) return false;
|
||||
return dims.width > dims.height * 1.5;
|
||||
}
|
||||
|
||||
shouldShowSinglePage(pageIndex: number): boolean {
|
||||
return this.isTwoPageView && this.isSpreadPage(pageIndex);
|
||||
}
|
||||
|
||||
// Shortcuts help dialog
|
||||
onShortcutsHelpClose(): void {
|
||||
this.showShortcutsHelp = false;
|
||||
}
|
||||
|
||||
// Long strip mode check
|
||||
get isLongStripMode(): boolean {
|
||||
return this.scrollMode === CbxScrollMode.LONG_STRIP;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopSlideshow();
|
||||
this.endReadingSession();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="dialog-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<button class="close-btn" (click)="onClose()">
|
||||
<app-reader-icon name="close" [size]="18"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
@for (group of shortcutGroups; track group.title) {
|
||||
<div class="shortcut-group">
|
||||
<h3 class="group-title">{{ group.title }}</h3>
|
||||
<div class="shortcuts-list">
|
||||
@for (shortcut of group.shortcuts; track shortcut.description) {
|
||||
<div class="shortcut-item">
|
||||
<div class="shortcut-keys">
|
||||
@for (key of shortcut.keys; track key; let last = $last) {
|
||||
<kbd class="key">{{ key }}</kbd>
|
||||
@if (!last) {
|
||||
<span class="key-separator">+</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="shortcut-info">
|
||||
<span class="shortcut-description">{{ shortcut.description }}</span>
|
||||
@if (isMobile && shortcut.mobileGesture) {
|
||||
<span class="mobile-gesture">{{ shortcut.mobileGesture }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-primary" (click)="onClose()">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,245 @@
|
||||
$dialog-bg: #1a1a1a;
|
||||
$text-primary: rgba(255, 255, 255, 0.95);
|
||||
$text-secondary: rgba(255, 255, 255, 0.6);
|
||||
$text-muted: rgba(255, 255, 255, 0.4);
|
||||
$border-color: rgba(255, 255, 255, 0.08);
|
||||
$hover-bg: rgba(255, 255, 255, 0.08);
|
||||
$active-color: #4a90e2;
|
||||
$transition-fast: 150ms ease;
|
||||
$transition-normal: 200ms ease;
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 13000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn $transition-normal;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: $dialog-bg;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
line-height: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-group {
|
||||
.group-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.key-separator {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.shortcut-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.shortcut-description {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.mobile-gesture {
|
||||
font-size: 11px;
|
||||
color: $active-color;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid $border-color;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: linear-gradient(0deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, transform $transition-fast;
|
||||
border: none;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: $active-color;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #5a9ee6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dialog {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-info {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {Component, EventEmitter, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {ReaderIconComponent} from '../../ebook-reader/shared/icon.component';
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string[];
|
||||
description: string;
|
||||
mobileGesture?: string;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: ShortcutItem[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-cbx-shortcuts-help',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
templateUrl: './cbx-shortcuts-help.component.html',
|
||||
styleUrls: ['./cbx-shortcuts-help.component.scss']
|
||||
})
|
||||
export class CbxShortcutsHelpComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
shortcutGroups: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{keys: ['←', '→'], description: 'Previous / Next page', mobileGesture: 'Swipe left/right'},
|
||||
{keys: ['Space'], description: 'Next page'},
|
||||
{keys: ['Shift', 'Space'], description: 'Previous page'},
|
||||
{keys: ['Home'], description: 'First page'},
|
||||
{keys: ['End'], description: 'Last page'},
|
||||
{keys: ['Page Up'], description: 'Previous page'},
|
||||
{keys: ['Page Down'], description: 'Next page'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
shortcuts: [
|
||||
{keys: ['F'], description: 'Toggle fullscreen'},
|
||||
{keys: ['D'], description: 'Toggle reading direction (LTR/RTL)'},
|
||||
{keys: ['Escape'], description: 'Exit fullscreen / Close dialogs'},
|
||||
{keys: ['Double-click'], description: 'Toggle zoom (fit page / actual size)', mobileGesture: 'Double-tap'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Playback',
|
||||
shortcuts: [
|
||||
{keys: ['P'], description: 'Toggle slideshow / auto-play'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
shortcuts: [
|
||||
{keys: ['?'], description: 'Show this help dialog'}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
isMobile = window.innerWidth < 768;
|
||||
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
onOverlayClick(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement).classList.contains('dialog-overlay')) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@
|
||||
</div>
|
||||
<span class="book-title">{{ bookTitle }}</span>
|
||||
<div class="header-right">
|
||||
<button class="icon-btn" (click)="onToggleSlideshow()" [title]="state.isSlideshowActive ? 'Stop Slideshow (P)' : 'Start Slideshow (P)'" [class.active]="state.isSlideshowActive">
|
||||
<app-reader-icon [name]="state.isSlideshowActive ? 'pause' : 'play'" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onToggleFullscreen()" [title]="state.isFullscreen ? 'Exit Fullscreen (F)' : 'Fullscreen (F)'">
|
||||
<app-reader-icon [name]="state.isFullscreen ? 'fullscreen-exit' : 'fullscreen'" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onShowShortcutsHelp()" title="Keyboard Shortcuts (?)">
|
||||
<app-reader-icon name="help" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onOpenSettings()" title="Settings">
|
||||
<app-reader-icon name="settings" [size]="20" />
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {CbxHeaderService} from './cbx-header.service';
|
||||
import {CbxHeaderService, CbxHeaderState} from './cbx-header.service';
|
||||
import {ReaderIconComponent} from '../../../ebook-reader';
|
||||
import {CommonModule} from '@angular/common';
|
||||
|
||||
@@ -20,6 +20,10 @@ export class CbxHeaderComponent implements OnInit, OnDestroy {
|
||||
@Input() currentPageHasNotes = false;
|
||||
|
||||
isVisible = true;
|
||||
state: CbxHeaderState = {
|
||||
isFullscreen: false,
|
||||
isSlideshowActive: false
|
||||
};
|
||||
|
||||
get bookTitle(): string {
|
||||
return this.headerService.title;
|
||||
@@ -29,6 +33,10 @@ export class CbxHeaderComponent implements OnInit, OnDestroy {
|
||||
this.headerService.forceVisible$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(visible => this.isVisible = visible);
|
||||
|
||||
this.headerService.state$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(state => this.state = state);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -52,6 +60,18 @@ export class CbxHeaderComponent implements OnInit, OnDestroy {
|
||||
this.headerService.openNoteDialog();
|
||||
}
|
||||
|
||||
onToggleFullscreen(): void {
|
||||
this.headerService.toggleFullscreen();
|
||||
}
|
||||
|
||||
onToggleSlideshow(): void {
|
||||
this.headerService.toggleSlideshow();
|
||||
}
|
||||
|
||||
onShowShortcutsHelp(): void {
|
||||
this.headerService.showShortcutsHelp();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.headerService.close();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import {Location} from '@angular/common';
|
||||
import {BehaviorSubject, Subject} from 'rxjs';
|
||||
import {CbxSidebarService} from '../sidebar/cbx-sidebar.service';
|
||||
|
||||
export interface CbxHeaderState {
|
||||
isFullscreen: boolean;
|
||||
isSlideshowActive: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CbxHeaderService {
|
||||
private sidebarService = inject(CbxSidebarService);
|
||||
@@ -15,6 +20,12 @@ export class CbxHeaderService {
|
||||
private _forceVisible = new BehaviorSubject<boolean>(true);
|
||||
forceVisible$ = this._forceVisible.asObservable();
|
||||
|
||||
private _state = new BehaviorSubject<CbxHeaderState>({
|
||||
isFullscreen: false,
|
||||
isSlideshowActive: false
|
||||
});
|
||||
state$ = this._state.asObservable();
|
||||
|
||||
private _showQuickSettings = new Subject<void>();
|
||||
showQuickSettings$ = this._showQuickSettings.asObservable();
|
||||
|
||||
@@ -24,6 +35,15 @@ export class CbxHeaderService {
|
||||
private _openNoteDialog = new Subject<void>();
|
||||
openNoteDialog$ = this._openNoteDialog.asObservable();
|
||||
|
||||
private _toggleFullscreen = new Subject<void>();
|
||||
toggleFullscreen$ = this._toggleFullscreen.asObservable();
|
||||
|
||||
private _toggleSlideshow = new Subject<void>();
|
||||
toggleSlideshow$ = this._toggleSlideshow.asObservable();
|
||||
|
||||
private _showShortcutsHelp = new Subject<void>();
|
||||
showShortcutsHelp$ = this._showShortcutsHelp.asObservable();
|
||||
|
||||
get title(): string {
|
||||
return this.bookTitle;
|
||||
}
|
||||
@@ -32,6 +52,10 @@ export class CbxHeaderService {
|
||||
return this._forceVisible.value;
|
||||
}
|
||||
|
||||
get state(): CbxHeaderState {
|
||||
return this._state.value;
|
||||
}
|
||||
|
||||
initialize(bookId: number, title: string | undefined, destroy$: Subject<void>): void {
|
||||
this.bookId = bookId;
|
||||
this.bookTitle = title || '';
|
||||
@@ -42,6 +66,10 @@ export class CbxHeaderService {
|
||||
this._forceVisible.next(visible);
|
||||
}
|
||||
|
||||
updateState(partial: Partial<CbxHeaderState>): void {
|
||||
this._state.next({...this._state.value, ...partial});
|
||||
}
|
||||
|
||||
openSidebar(): void {
|
||||
this.sidebarService.open();
|
||||
}
|
||||
@@ -58,12 +86,25 @@ export class CbxHeaderService {
|
||||
this._openNoteDialog.next();
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
this._toggleFullscreen.next();
|
||||
}
|
||||
|
||||
toggleSlideshow(): void {
|
||||
this._toggleSlideshow.next();
|
||||
}
|
||||
|
||||
showShortcutsHelp(): void {
|
||||
this._showShortcutsHelp.next();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._forceVisible.next(true);
|
||||
this._state.next({isFullscreen: false, isSlideshowActive: false});
|
||||
this.bookTitle = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,17 @@
|
||||
<div class="control">
|
||||
<label>Scroll Mode</label>
|
||||
<div class="control-right">
|
||||
<span class="mode-label">{{ state.scrollMode === CbxScrollMode.PAGINATED ? 'Paginated' : 'Infinite' }}</span>
|
||||
<button class="switch" [class.active]="state.scrollMode === CbxScrollMode.INFINITE" (click)="onScrollModeToggle()">
|
||||
<span class="slider"></span>
|
||||
<div class="button-group text-btns">
|
||||
@for (option of scrollModeOptions; track option.value) {
|
||||
<button
|
||||
class="option-btn text-btn"
|
||||
[class.active]="state.scrollMode === option.value"
|
||||
(click)="onScrollModeSelect(option.value)"
|
||||
[title]="option.label">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +64,35 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Reading Direction -->
|
||||
<div class="control">
|
||||
<label>Reading Direction</label>
|
||||
<div class="control-right">
|
||||
<span class="mode-label">{{ state.readingDirection === CbxReadingDirection.LTR ? 'Left to Right' : 'Right to Left' }}</span>
|
||||
<button class="switch" [class.active]="state.readingDirection === CbxReadingDirection.RTL" (click)="onReadingDirectionToggle()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slideshow Interval -->
|
||||
<div class="control">
|
||||
<label>Slideshow Interval</label>
|
||||
<div class="control-right">
|
||||
<div class="button-group text-btns">
|
||||
@for (option of slideshowIntervalOptions; track option.value) {
|
||||
<button
|
||||
class="option-btn text-btn small"
|
||||
[class.active]="state.slideshowInterval === option.value"
|
||||
(click)="onSlideshowIntervalSelect(option.value)"
|
||||
[title]="option.label">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background Color -->
|
||||
<div class="control">
|
||||
<label>Background</label>
|
||||
|
||||
@@ -30,7 +30,7 @@ $transition-normal: 200ms ease;
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
right: 60px;
|
||||
width: 320px;
|
||||
width: 340px;
|
||||
background: $panel-bg;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 12px;
|
||||
@@ -175,6 +175,18 @@ $transition-normal: 200ms ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.text-btn {
|
||||
width: auto;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
&.small {
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.color-btn {
|
||||
padding: 4px;
|
||||
|
||||
@@ -192,6 +204,10 @@ $transition-normal: 200ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.text-btns {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.quick-settings-panel {
|
||||
right: 16px;
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
CbxPageViewMode,
|
||||
CbxPageSpread,
|
||||
CbxBackgroundColor,
|
||||
CbxReadingDirection,
|
||||
CbxSlideshowInterval,
|
||||
PdfPageViewMode,
|
||||
PdfPageSpread
|
||||
} from '../../../../settings/user-management/user.service';
|
||||
@@ -30,7 +32,9 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
scrollMode: CbxScrollMode.PAGINATED,
|
||||
pageViewMode: CbxPageViewMode.SINGLE_PAGE,
|
||||
pageSpread: CbxPageSpread.ODD,
|
||||
backgroundColor: CbxBackgroundColor.GRAY
|
||||
backgroundColor: CbxBackgroundColor.GRAY,
|
||||
readingDirection: CbxReadingDirection.LTR,
|
||||
slideshowInterval: CbxSlideshowInterval.FIVE_SECONDS
|
||||
};
|
||||
|
||||
protected readonly CbxFitMode = CbxFitMode;
|
||||
@@ -38,6 +42,8 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
protected readonly CbxPageViewMode = CbxPageViewMode;
|
||||
protected readonly CbxPageSpread = CbxPageSpread;
|
||||
protected readonly CbxBackgroundColor = CbxBackgroundColor;
|
||||
protected readonly CbxReadingDirection = CbxReadingDirection;
|
||||
protected readonly CbxSlideshowInterval = CbxSlideshowInterval;
|
||||
|
||||
fitModeOptions: {value: CbxFitMode, label: string, icon: ReaderIconName}[] = [
|
||||
{value: CbxFitMode.FIT_PAGE, label: 'Fit Page', icon: 'fit-page'},
|
||||
@@ -47,6 +53,20 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
{value: CbxFitMode.AUTO, label: 'Automatic', icon: 'auto-fit'}
|
||||
];
|
||||
|
||||
scrollModeOptions: {value: CbxScrollMode, label: string}[] = [
|
||||
{value: CbxScrollMode.PAGINATED, label: 'Paginated'},
|
||||
{value: CbxScrollMode.INFINITE, label: 'Infinite'},
|
||||
{value: CbxScrollMode.LONG_STRIP, label: 'Long Strip'}
|
||||
];
|
||||
|
||||
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'}
|
||||
];
|
||||
|
||||
backgroundOptions = [
|
||||
{value: CbxBackgroundColor.BLACK, label: 'Black', color: '#000000'},
|
||||
{value: CbxBackgroundColor.GRAY, label: 'Gray', color: '#808080'},
|
||||
@@ -72,19 +92,28 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
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;
|
||||
}
|
||||
|
||||
get currentScrollModeLabel(): string {
|
||||
return this.scrollModeOptions.find(o => o.value === this.state.scrollMode)?.label || 'Paginated';
|
||||
}
|
||||
|
||||
get currentSlideshowIntervalLabel(): string {
|
||||
return this.slideshowIntervalOptions.find(o => o.value === this.state.slideshowInterval)?.label || '5s';
|
||||
}
|
||||
|
||||
onFitModeSelect(mode: CbxFitMode): void {
|
||||
this.quickSettingsService.emitFitModeChange(mode);
|
||||
}
|
||||
|
||||
onScrollModeToggle(): void {
|
||||
const newMode = this.state.scrollMode === CbxScrollMode.PAGINATED
|
||||
? CbxScrollMode.INFINITE
|
||||
: CbxScrollMode.PAGINATED;
|
||||
this.quickSettingsService.emitScrollModeChange(newMode);
|
||||
onScrollModeSelect(mode: CbxScrollMode): void {
|
||||
this.quickSettingsService.emitScrollModeChange(mode);
|
||||
}
|
||||
|
||||
onPageViewToggle(): void {
|
||||
@@ -105,6 +134,17 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
this.quickSettingsService.emitBackgroundColorChange(color);
|
||||
}
|
||||
|
||||
onReadingDirectionToggle(): void {
|
||||
const newDirection = this.state.readingDirection === CbxReadingDirection.LTR
|
||||
? CbxReadingDirection.RTL
|
||||
: CbxReadingDirection.LTR;
|
||||
this.quickSettingsService.emitReadingDirectionChange(newDirection);
|
||||
}
|
||||
|
||||
onSlideshowIntervalSelect(interval: CbxSlideshowInterval): void {
|
||||
this.quickSettingsService.emitSlideshowIntervalChange(interval);
|
||||
}
|
||||
|
||||
onOverlayClick(): void {
|
||||
this.quickSettingsService.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {BehaviorSubject, Subject} from 'rxjs';
|
||||
import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrollMode, PdfBackgroundColor, PdfFitMode, PdfPageSpread, PdfPageViewMode, PdfScrollMode} from '../../../../settings/user-management/user.service';
|
||||
import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrollMode, CbxReadingDirection, CbxSlideshowInterval, PdfBackgroundColor, PdfFitMode, PdfPageSpread, PdfPageViewMode, PdfScrollMode} from '../../../../settings/user-management/user.service';
|
||||
|
||||
export interface CbxQuickSettingsState {
|
||||
fitMode: CbxFitMode | PdfFitMode;
|
||||
@@ -8,6 +8,8 @@ export interface CbxQuickSettingsState {
|
||||
pageViewMode: CbxPageViewMode | PdfPageViewMode;
|
||||
pageSpread: CbxPageSpread | PdfPageSpread;
|
||||
backgroundColor: CbxBackgroundColor | PdfBackgroundColor;
|
||||
readingDirection: CbxReadingDirection;
|
||||
slideshowInterval: CbxSlideshowInterval;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -17,7 +19,9 @@ export class CbxQuickSettingsService {
|
||||
scrollMode: CbxScrollMode.PAGINATED,
|
||||
pageViewMode: CbxPageViewMode.SINGLE_PAGE,
|
||||
pageSpread: CbxPageSpread.ODD,
|
||||
backgroundColor: CbxBackgroundColor.GRAY
|
||||
backgroundColor: CbxBackgroundColor.GRAY,
|
||||
readingDirection: CbxReadingDirection.LTR,
|
||||
slideshowInterval: CbxSlideshowInterval.FIVE_SECONDS
|
||||
});
|
||||
state$ = this._state.asObservable();
|
||||
|
||||
@@ -39,6 +43,12 @@ export class CbxQuickSettingsService {
|
||||
private _backgroundColorChange = new Subject<CbxBackgroundColor>();
|
||||
backgroundColorChange$ = this._backgroundColorChange.asObservable();
|
||||
|
||||
private _readingDirectionChange = new Subject<CbxReadingDirection>();
|
||||
readingDirectionChange$ = this._readingDirectionChange.asObservable();
|
||||
|
||||
private _slideshowIntervalChange = new Subject<CbxSlideshowInterval>();
|
||||
slideshowIntervalChange$ = this._slideshowIntervalChange.asObservable();
|
||||
|
||||
get state(): CbxQuickSettingsState {
|
||||
return this._state.value;
|
||||
}
|
||||
@@ -79,6 +89,14 @@ export class CbxQuickSettingsService {
|
||||
this.updateState({backgroundColor: color});
|
||||
}
|
||||
|
||||
setReadingDirection(direction: CbxReadingDirection): void {
|
||||
this.updateState({readingDirection: direction});
|
||||
}
|
||||
|
||||
setSlideshowInterval(interval: CbxSlideshowInterval): void {
|
||||
this.updateState({slideshowInterval: interval});
|
||||
}
|
||||
|
||||
// Actions emitted from component
|
||||
emitFitModeChange(mode: CbxFitMode): void {
|
||||
this._fitModeChange.next(mode);
|
||||
@@ -100,13 +118,23 @@ export class CbxQuickSettingsService {
|
||||
this._backgroundColorChange.next(color);
|
||||
}
|
||||
|
||||
emitReadingDirectionChange(direction: CbxReadingDirection): void {
|
||||
this._readingDirectionChange.next(direction);
|
||||
}
|
||||
|
||||
emitSlideshowIntervalChange(interval: CbxSlideshowInterval): void {
|
||||
this._slideshowIntervalChange.next(interval);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._state.next({
|
||||
fitMode: CbxFitMode.FIT_PAGE,
|
||||
scrollMode: CbxScrollMode.PAGINATED,
|
||||
pageViewMode: CbxPageViewMode.SINGLE_PAGE,
|
||||
pageSpread: CbxPageSpread.ODD,
|
||||
backgroundColor: CbxBackgroundColor.GRAY
|
||||
backgroundColor: CbxBackgroundColor.GRAY,
|
||||
readingDirection: CbxReadingDirection.LTR,
|
||||
slideshowInterval: CbxSlideshowInterval.FIVE_SECONDS
|
||||
});
|
||||
this._visible.next(false);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,15 @@ export type ReaderIconName =
|
||||
| 'fit-width'
|
||||
| 'fit-height'
|
||||
| 'actual-size'
|
||||
| 'auto-fit';
|
||||
| 'auto-fit'
|
||||
| 'fullscreen'
|
||||
| 'fullscreen-exit'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'help'
|
||||
| 'long-strip'
|
||||
| 'direction-ltr'
|
||||
| 'direction-rtl';
|
||||
|
||||
interface IconPath {
|
||||
d: string;
|
||||
@@ -147,6 +155,43 @@ const ICONS: Record<ReaderIconName, IconPath[]> = {
|
||||
'auto-fit': [
|
||||
{d: 'M12 3v3m0 12v3M3 12h3m12 0h3'},
|
||||
{d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0-8 0', type: 'path'}
|
||||
],
|
||||
'fullscreen': [
|
||||
{d: 'M8 3H5a2 2 0 0 0-2 2v3'},
|
||||
{d: 'M21 8V5a2 2 0 0 0-2-2h-3'},
|
||||
{d: 'M3 16v3a2 2 0 0 0 2 2h3'},
|
||||
{d: 'M16 21h3a2 2 0 0 0 2-2v-3'}
|
||||
],
|
||||
'fullscreen-exit': [
|
||||
{d: 'M8 3v3a2 2 0 0 1-2 2H3'},
|
||||
{d: 'M21 8h-3a2 2 0 0 1-2-2V3'},
|
||||
{d: 'M3 16h3a2 2 0 0 1 2 2v3'},
|
||||
{d: 'M16 21v-3a2 2 0 0 1 2-2h3'}
|
||||
],
|
||||
'play': [
|
||||
{d: 'M5 3l14 9-14 9V3z'}
|
||||
],
|
||||
'pause': [
|
||||
{d: 'M6,4 L6,20', type: 'line'},
|
||||
{d: 'M18,4 L18,20', type: 'line'}
|
||||
],
|
||||
'help': [
|
||||
{d: 'M12 12m-10 0a10 10 0 1 0 20 0a10 10 0 1 0-20 0', type: 'path'},
|
||||
{d: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'},
|
||||
{d: 'M12 17h.01'}
|
||||
],
|
||||
'long-strip': [
|
||||
{d: 'M4 3h16v6H4z'},
|
||||
{d: 'M4 9h16v6H4z'},
|
||||
{d: 'M4 15h16v6H4z'}
|
||||
],
|
||||
'direction-ltr': [
|
||||
{d: 'M5,12 L19,12', type: 'line'},
|
||||
{d: 'M15,8 L19,12 L15,16', type: 'polyline'}
|
||||
],
|
||||
'direction-rtl': [
|
||||
{d: 'M19,12 L5,12', type: 'line'},
|
||||
{d: 'M9,8 L5,12 L9,16', type: 'polyline'}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -85,7 +85,21 @@ export enum CbxFitMode {
|
||||
|
||||
export enum CbxScrollMode {
|
||||
PAGINATED = 'PAGINATED',
|
||||
INFINITE = 'INFINITE'
|
||||
INFINITE = 'INFINITE',
|
||||
LONG_STRIP = 'LONG_STRIP'
|
||||
}
|
||||
|
||||
export enum CbxReadingDirection {
|
||||
LTR = 'LTR',
|
||||
RTL = 'RTL'
|
||||
}
|
||||
|
||||
export enum CbxSlideshowInterval {
|
||||
THREE_SECONDS = 3000,
|
||||
FIVE_SECONDS = 5000,
|
||||
TEN_SECONDS = 10000,
|
||||
FIFTEEN_SECONDS = 15000,
|
||||
THIRTY_SECONDS = 30000
|
||||
}
|
||||
|
||||
export interface PdfReaderSetting {
|
||||
@@ -164,6 +178,8 @@ export interface CbxReaderSetting {
|
||||
fitMode: CbxFitMode;
|
||||
scrollMode?: CbxScrollMode;
|
||||
backgroundColor?: CbxBackgroundColor;
|
||||
readingDirection?: CbxReadingDirection;
|
||||
slideshowInterval?: CbxSlideshowInterval;
|
||||
}
|
||||
|
||||
export interface TableColumnPreference {
|
||||
|
||||
Reference in New Issue
Block a user