@for (url of infiniteScrollImageUrls; track url; let i = $index) {
-
![Page {{ infiniteScrollPages[i] + 1 }}]()
+
![Page {{ infiniteScrollPages[i] + 1 }}]()
}
@if (isLoadingMore) {
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss
index 8905fae3d..dbbec9a53 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss
+++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss
@@ -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;
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts
index 70af178e6..069e701d0 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts
+++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts
@@ -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
| 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();
+
+ 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,43 +615,23 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
}
nextPage() {
- const previousPage = this.currentPage;
-
- if (this.scrollMode === CbxScrollMode.INFINITE) {
- if (this.currentPage < this.pages.length - 1) {
- this.currentPage++;
- this.scrollToPage(this.currentPage);
- this.updateProgress();
- this.updateSessionProgress();
- this.updateFooterPage();
- }
- return;
- }
-
- if (this.isTwoPageView) {
- if (this.currentPage + 2 < this.pages.length) {
- this.currentPage += 2;
- } 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();
- }
+ this.pauseSlideshowOnInteraction();
+ this.advancePage(1);
}
previousPage() {
- const previousPage = this.currentPage;
+ this.pauseSlideshowOnInteraction();
+ this.advancePage(-1);
+ }
- if (this.scrollMode === CbxScrollMode.INFINITE) {
- if (this.currentPage > 0) {
- this.currentPage--;
+ 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();
@@ -601,10 +640,25 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
return;
}
- if (this.isTwoPageView) {
- this.currentPage = Math.max(0, this.currentPage - 2);
+ 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;
+ }
+ } else if (this.currentPage < this.pages.length - 1) {
+ this.currentPage++;
+ }
} else {
- this.currentPage = Math.max(0, this.currentPage - 1);
+ // Backward navigation
+ if (this.isTwoPageView) {
+ this.currentPage = Math.max(0, this.currentPage - step);
+ } else {
+ this.currentPage = Math.max(0, this.currentPage - 1);
+ }
}
if (this.currentPage !== previousPage) {
@@ -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();
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.html b/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.html
new file mode 100644
index 000000000..6b39b6407
--- /dev/null
+++ b/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ @for (group of shortcutGroups; track group.title) {
+
+
{{ group.title }}
+
+ @for (shortcut of group.shortcuts; track shortcut.description) {
+
+
+ @for (key of shortcut.keys; track key; let last = $last) {
+ {{ key }}
+ @if (!last) {
+ +
+ }
+ }
+
+
+ {{ shortcut.description }}
+ @if (isMobile && shortcut.mobileGesture) {
+ {{ shortcut.mobileGesture }}
+ }
+
+
+ }
+
+
+ }
+
+
+
+
+
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.scss b/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.scss
new file mode 100644
index 000000000..a932f5308
--- /dev/null
+++ b/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.scss
@@ -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;
+ }
+}
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.ts b/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.ts
new file mode 100644
index 000000000..3bb742dd0
--- /dev/null
+++ b/booklore-ui/src/app/features/readers/cbx-reader/dialogs/cbx-shortcuts-help.component.ts
@@ -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();
+
+ 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();
+ }
+ }
+}
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/layout/header/cbx-header.component.html b/booklore-ui/src/app/features/readers/cbx-reader/layout/header/cbx-header.component.html
index 27eb281cb..5d633efb4 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/layout/header/cbx-header.component.html
+++ b/booklore-ui/src/app/features/readers/cbx-reader/layout/header/cbx-header.component.html
@@ -15,6 +15,15 @@
{{ bookTitle }}
}
+
+
+
+
+ {{ state.readingDirection === CbxReadingDirection.LTR ? 'Left to Right' : 'Right to Left' }}
+
+
+
+
+
+
+
+
+
+ @for (option of slideshowIntervalOptions; track option.value) {
+
+ }
+
+
+
+
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.scss b/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.scss
index 480b8bc2a..8dc5d6b42 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.scss
+++ b/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.scss
@@ -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;
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts b/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts
index 9853e6572..db02fba5b 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts
+++ b/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.component.ts
@@ -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();
}
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts b/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts
index d2392deb6..62cfde0a3 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts
+++ b/booklore-ui/src/app/features/readers/cbx-reader/layout/quick-settings/cbx-quick-settings.service.ts
@@ -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();
backgroundColorChange$ = this._backgroundColorChange.asObservable();
+ private _readingDirectionChange = new Subject();
+ readingDirectionChange$ = this._readingDirectionChange.asObservable();
+
+ private _slideshowIntervalChange = new Subject();
+ 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);
}
diff --git a/booklore-ui/src/app/features/readers/ebook-reader/shared/icon.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/shared/icon.component.ts
index d4e0c1a5e..660366668 100644
--- a/booklore-ui/src/app/features/readers/ebook-reader/shared/icon.component.ts
+++ b/booklore-ui/src/app/features/readers/ebook-reader/shared/icon.component.ts
@@ -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 = {
'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'}
]
};
diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.ts b/booklore-ui/src/app/features/settings/user-management/user.service.ts
index 544fe10b3..c95d078b1 100644
--- a/booklore-ui/src/app/features/settings/user-management/user.service.ts
+++ b/booklore-ui/src/app/features/settings/user-management/user.service.ts
@@ -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 {