feat(cbx-reader): add fullscreen, slideshow, RTL, long strip mode and keyboard shortcuts (#2632)

This commit is contained in:
ACX
2026-02-05 23:28:02 -07:00
committed by GitHub
parent 37ea0b156f
commit 5183b0ddfc
15 changed files with 1075 additions and 69 deletions

View File

@@ -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">

View File

@@ -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;

View File

@@ -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,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();

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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 = '';
}
}

View File

@@ -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>
</button>
<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>

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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'}
]
};

View File

@@ -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 {