mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(reader): add fullscreen, keyboard shortcuts help, search cancel, and go-to-percentage to ebook reader (#2698)
This commit is contained in:
@@ -3,7 +3,7 @@ import {Subject} from 'rxjs';
|
||||
import {ReaderAnnotationService} from '../features/annotations/annotation-renderer.service';
|
||||
|
||||
export interface ViewEvent {
|
||||
type: 'load' | 'relocate' | 'error' | 'middle-single-tap' | 'draw-annotation' | 'show-annotation' | 'text-selected';
|
||||
type: 'load' | 'relocate' | 'error' | 'middle-single-tap' | 'draw-annotation' | 'show-annotation' | 'text-selected' | 'toggle-fullscreen' | 'toggle-shortcuts-help' | 'escape-pressed' | 'go-first-section' | 'go-last-section' | 'toggle-toc' | 'toggle-search' | 'toggle-notes';
|
||||
detail?: any;
|
||||
popupPosition?: { x: number; y: number; showBelow?: boolean };
|
||||
}
|
||||
@@ -127,12 +127,42 @@ export class ReaderEventService {
|
||||
return;
|
||||
}
|
||||
const k = event.key;
|
||||
if (k === 'ArrowLeft' || k === 'h' || k === 'PageUp') {
|
||||
if (k === 'ArrowLeft' || k === 'PageUp') {
|
||||
this.viewCallbacks?.prev();
|
||||
event.preventDefault();
|
||||
} else if (k === 'ArrowRight' || k === 'l' || k === 'PageDown') {
|
||||
} else if (k === 'ArrowRight' || k === 'PageDown') {
|
||||
this.viewCallbacks?.next();
|
||||
event.preventDefault();
|
||||
} else if (k === ' ' && event.shiftKey) {
|
||||
this.viewCallbacks?.prev();
|
||||
event.preventDefault();
|
||||
} else if (k === ' ') {
|
||||
this.viewCallbacks?.next();
|
||||
event.preventDefault();
|
||||
} else if (k === 'Home') {
|
||||
this.eventSubject.next({type: 'go-first-section'});
|
||||
event.preventDefault();
|
||||
} else if (k === 'End') {
|
||||
this.eventSubject.next({type: 'go-last-section'});
|
||||
event.preventDefault();
|
||||
} else if (k === 'f' || k === 'F') {
|
||||
this.eventSubject.next({type: 'toggle-fullscreen'});
|
||||
event.preventDefault();
|
||||
} else if (k === 't' || k === 'T') {
|
||||
this.eventSubject.next({type: 'toggle-toc'});
|
||||
event.preventDefault();
|
||||
} else if (k === 's' || k === 'S') {
|
||||
this.eventSubject.next({type: 'toggle-search'});
|
||||
event.preventDefault();
|
||||
} else if (k === 'n' || k === 'N') {
|
||||
this.eventSubject.next({type: 'toggle-notes'});
|
||||
event.preventDefault();
|
||||
} else if (k === '?') {
|
||||
this.eventSubject.next({type: 'toggle-shortcuts-help'});
|
||||
event.preventDefault();
|
||||
} else if (k === 'Escape') {
|
||||
this.eventSubject.next({type: 'escape-pressed'});
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this.keydownHandler);
|
||||
|
||||
@@ -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: #818cf8;
|
||||
$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: #9ba3fb;
|
||||
}
|
||||
}
|
||||
|
||||
@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,74 @@
|
||||
import {Component, EventEmitter, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {ReaderIconComponent} from '../shared/icon.component';
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string[];
|
||||
description: string;
|
||||
mobileGesture?: string;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: ShortcutItem[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-ebook-shortcuts-help',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
templateUrl: './shortcuts-help.component.html',
|
||||
styleUrls: ['./shortcuts-help.component.scss']
|
||||
})
|
||||
export class EbookShortcutsHelpComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
shortcutGroups: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{keys: ['←'], description: 'Previous page', mobileGesture: 'Swipe right'},
|
||||
{keys: ['→'], description: 'Next page', mobileGesture: 'Swipe left'},
|
||||
{keys: ['Space'], description: 'Next page'},
|
||||
{keys: ['Shift', 'Space'], description: 'Previous page'},
|
||||
{keys: ['Home'], description: 'First section'},
|
||||
{keys: ['End'], description: 'Last section'},
|
||||
{keys: ['Page Up'], description: 'Previous page'},
|
||||
{keys: ['Page Down'], description: 'Next page'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Panels',
|
||||
shortcuts: [
|
||||
{keys: ['T'], description: 'Table of contents'},
|
||||
{keys: ['S'], description: 'Search'},
|
||||
{keys: ['N'], description: 'Notes'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
shortcuts: [
|
||||
{keys: ['F'], description: 'Toggle fullscreen'},
|
||||
{keys: ['Escape'], description: 'Exit fullscreen / Close dialogs'}
|
||||
]
|
||||
},
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,4 +82,8 @@
|
||||
(cancel)="onNoteCancel()"
|
||||
></app-reader-note-dialog>
|
||||
}
|
||||
|
||||
@if (showShortcutsHelp) {
|
||||
<app-ebook-shortcuts-help (close)="showShortcutsHelp = false"></app-ebook-shortcuts-help>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {ReaderHeaderFooterVisibilityManager} from './shared/visibility.util';
|
||||
import {EpubCustomFontService} from './features/fonts/custom-font.service';
|
||||
import {TextSelectionAction, TextSelectionPopupComponent} from './shared/selection-popup.component';
|
||||
import {NoteDialogData, NoteDialogResult, ReaderNoteDialogComponent} from './dialogs/note-dialog.component';
|
||||
import {EbookShortcutsHelpComponent} from './dialogs/shortcuts-help.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ebook-reader',
|
||||
@@ -43,7 +44,8 @@ import {NoteDialogData, NoteDialogResult, ReaderNoteDialogComponent} from './dia
|
||||
ReaderLeftSidebarComponent,
|
||||
ReaderNavbarComponent,
|
||||
TextSelectionPopupComponent,
|
||||
ReaderNoteDialogComponent
|
||||
ReaderNoteDialogComponent,
|
||||
EbookShortcutsHelpComponent
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
providers: [
|
||||
@@ -109,6 +111,8 @@ export class EbookReaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
showNoteDialog = false;
|
||||
noteDialogData: NoteDialogData | null = null;
|
||||
isFullscreen = false;
|
||||
showShortcutsHelp = false;
|
||||
|
||||
get currentProgressData(): any {
|
||||
return this.progressService.currentProgressData;
|
||||
@@ -151,6 +155,14 @@ export class EbookReaderComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.showMetadata = true);
|
||||
|
||||
this.headerService.toggleFullscreen$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.toggleFullscreen());
|
||||
|
||||
this.headerService.showShortcutsHelp$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.showShortcutsHelp = true);
|
||||
|
||||
this.isLoading = true;
|
||||
this.initializeFoliate().pipe(
|
||||
switchMap(() => this.epubCustomFontService.loadAndCacheFonts()),
|
||||
@@ -312,6 +324,46 @@ export class EbookReaderComponent implements OnInit, OnDestroy {
|
||||
case 'text-selected':
|
||||
this.selectionService.handleTextSelected(event.detail, event.popupPosition);
|
||||
break;
|
||||
case 'toggle-fullscreen':
|
||||
this.toggleFullscreen();
|
||||
break;
|
||||
case 'toggle-shortcuts-help':
|
||||
this.showShortcutsHelp = !this.showShortcutsHelp;
|
||||
break;
|
||||
case 'go-first-section':
|
||||
this.viewManager.goToSection(0).subscribe();
|
||||
break;
|
||||
case 'go-last-section': {
|
||||
const s = this.progressService.currentProgressData?.section;
|
||||
if (s && s.total > 0) {
|
||||
this.viewManager.goToSection(s.total - 1).subscribe();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'toggle-toc':
|
||||
this.sidebarService.toggle('chapters');
|
||||
break;
|
||||
case 'toggle-search':
|
||||
this.leftSidebarService.toggle('search');
|
||||
break;
|
||||
case 'toggle-notes':
|
||||
this.leftSidebarService.toggle('notes');
|
||||
break;
|
||||
case 'escape-pressed':
|
||||
if (this.showShortcutsHelp) {
|
||||
this.showShortcutsHelp = false;
|
||||
} else if (this.showNoteDialog) {
|
||||
this.noteService.closeDialog();
|
||||
} else if (this.showControls) {
|
||||
this.showControls = false;
|
||||
} else if (this.showQuickSettings) {
|
||||
this.showQuickSettings = false;
|
||||
} else if (this.showMetadata) {
|
||||
this.showMetadata = false;
|
||||
} else if (this.isFullscreen) {
|
||||
this.exitFullscreen();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -342,6 +394,28 @@ export class EbookReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
this.isFullscreen = !!document.fullscreenElement;
|
||||
this.headerService.setFullscreen(this.isFullscreen);
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
if (document.fullscreenElement) {
|
||||
this.exitFullscreen();
|
||||
} else {
|
||||
this.enterFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
private enterFullscreen(): void {
|
||||
document.documentElement.requestFullscreen?.();
|
||||
}
|
||||
|
||||
private exitFullscreen(): void {
|
||||
document.exitFullscreen?.();
|
||||
}
|
||||
|
||||
onProgressChange(fraction: number): void {
|
||||
this.viewManager.goToFraction(fraction)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
@if (showLocationPopover) {
|
||||
<div class="location-popover" (mousedown)="$event.preventDefault()">
|
||||
<div class="location-popover">
|
||||
<div class="popover-content">
|
||||
<div class="time-info">
|
||||
<div class="time-section">
|
||||
@@ -47,41 +47,54 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="location-controls">
|
||||
<div class="control-row">
|
||||
<label>Page</label>
|
||||
<span>{{ currentPage }}</span>
|
||||
<div class="book-info">
|
||||
<div class="chapter-info">
|
||||
<span class="label">Chapter</span>
|
||||
<span class="chapter-name">{{ currentChapter }}</span>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label>Location</label>
|
||||
<input type="number" [value]="locationCurrent"/>
|
||||
<span>/ {{ locationTotal }}</span>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label>Section</label>
|
||||
<span>{{ sectionCurrent }} / {{ sectionTotal }}</span>
|
||||
<div class="info-row">
|
||||
<div class="info-column">
|
||||
<span class="label">Section</span>
|
||||
<span class="value">{{ sectionCurrent }} / {{ sectionTotal }}</span>
|
||||
</div>
|
||||
@if (progressData?.pageItem) {
|
||||
<div class="info-separator"></div>
|
||||
<div class="info-column">
|
||||
<span class="label">Page</span>
|
||||
<span class="value">{{ currentPage }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="section-navigation">
|
||||
<div class="nav-section">
|
||||
<div class="goto-row">
|
||||
<label>Go to</label>
|
||||
<input type="number" [value]="currentPercentage" #percentInput
|
||||
(keydown.enter)="onGoToPercentage(percentInput.value)" [min]="0" [max]="100"/>
|
||||
<span>%</span>
|
||||
<button class="go-btn" (click)="onGoToPercentage(percentInput.value)" title="Go to percentage">Go</button>
|
||||
</div>
|
||||
<div class="section-navigation">
|
||||
<button class="nav-btn" title="First Section" (click)="onFirstSection()">
|
||||
<app-reader-icon name="chevron-first" [size]="16"></app-reader-icon>
|
||||
<app-reader-icon name="chevron-first" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Previous Section" (click)="onPreviousSection()">
|
||||
<app-reader-icon name="chevron-left" [size]="16"></app-reader-icon>
|
||||
<app-reader-icon name="chevron-left" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Jump To...">
|
||||
<app-reader-icon name="dots-vertical" [size]="16"></app-reader-icon>
|
||||
<app-reader-icon name="dots-vertical" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Next Section" (click)="onNextSection()">
|
||||
<app-reader-icon name="chevron-right" [size]="16"></app-reader-icon>
|
||||
<app-reader-icon name="chevron-right" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Last Section" (click)="onLastSection()">
|
||||
<app-reader-icon name="chevron-last" [size]="16"></app-reader-icon>
|
||||
<app-reader-icon name="chevron-last" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ $transition-normal: 200ms ease;
|
||||
color: $text-primary;
|
||||
|
||||
.popover-content {
|
||||
padding: 20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
@@ -197,12 +197,12 @@ $transition-normal: 200ms ease;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0;
|
||||
padding: 4px 0;
|
||||
|
||||
.time-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
|
||||
@@ -232,49 +232,135 @@ $transition-normal: 200ms ease;
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: $border-color;
|
||||
margin: 16px 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
.book-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
|
||||
.control-row {
|
||||
.chapter-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
|
||||
label {
|
||||
min-width: 70px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 65px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
.chapter-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
transition: background $transition-fast, border-color $transition-fast;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.3;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: $hover-bg;
|
||||
border-color: $active-color;
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
|
||||
.info-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
.info-separator {
|
||||
width: 1px;
|
||||
height: 36px;
|
||||
background: $border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.goto-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 65px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
color: $text-primary;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
transition: background $transition-fast, border-color $transition-fast;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: $hover-bg;
|
||||
border-color: $active-color;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.go-btn {
|
||||
padding: 7px 18px;
|
||||
background: $active-color;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: #9ba3fb;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,8 +380,8 @@ $transition-normal: 200ms ease;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast, transform $transition-fast;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -88,14 +88,6 @@ export class ReaderNavbarComponent {
|
||||
return this.formatDuration((this.progressData?.time.section ?? 0) * 60);
|
||||
}
|
||||
|
||||
get locationCurrent(): number {
|
||||
return this.progressData?.location.current ?? 0;
|
||||
}
|
||||
|
||||
get locationTotal(): number {
|
||||
return this.progressData?.location.total ?? 0;
|
||||
}
|
||||
|
||||
get sectionCurrent(): number {
|
||||
return this.progressData?.section.current ?? 0;
|
||||
}
|
||||
@@ -104,8 +96,12 @@ export class ReaderNavbarComponent {
|
||||
return this.progressData?.section.total ?? 0;
|
||||
}
|
||||
|
||||
get currentChapter(): string {
|
||||
return this.progressData?.tocItem?.label ?? '';
|
||||
}
|
||||
|
||||
get currentPage(): string {
|
||||
return this.progressData?.pageItem?.label ?? 'N/A';
|
||||
return this.progressData?.pageItem?.label ?? '';
|
||||
}
|
||||
|
||||
get navbarVisible(): boolean {
|
||||
@@ -128,6 +124,13 @@ export class ReaderNavbarComponent {
|
||||
this.progressChange.emit(fraction);
|
||||
}
|
||||
|
||||
onGoToPercentage(value: string): void {
|
||||
const percentage = parseFloat(value);
|
||||
if (isNaN(percentage) || percentage < 0 || percentage > 100) return;
|
||||
const fraction = percentage / 100;
|
||||
this.progressChange.emit(fraction);
|
||||
}
|
||||
|
||||
onFirstSection() {
|
||||
this.managerService.goToSection(0).subscribe();
|
||||
}
|
||||
|
||||
@@ -12,9 +12,15 @@
|
||||
<button class="icon-btn" (click)="onOpenNotes()" title="Notes">
|
||||
<app-reader-icon name="note"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onToggleFullscreen()" [title]="isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'">
|
||||
<app-reader-icon [name]="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onShowControls()" title="Settings">
|
||||
<app-reader-icon name="settings"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onShowHelp()" title="Keyboard Shortcuts">
|
||||
<app-reader-icon name="help" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onClose(); $event.stopPropagation()" title="Close Reader">
|
||||
<app-reader-icon name="close"></app-reader-icon>
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,7 @@ export class ReaderHeaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
isVisible = false;
|
||||
isCurrentCfiBookmarked = false;
|
||||
isFullscreen = false;
|
||||
|
||||
get bookTitle(): string {
|
||||
return this.headerService.title;
|
||||
@@ -36,6 +37,10 @@ export class ReaderHeaderComponent implements OnInit, OnDestroy {
|
||||
this.headerService.isCurrentCfiBookmarked$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(bookmarked => this.isCurrentCfiBookmarked = bookmarked);
|
||||
|
||||
this.headerService.fullscreenState$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(fs => this.isFullscreen = fs);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -63,6 +68,14 @@ export class ReaderHeaderComponent implements OnInit, OnDestroy {
|
||||
this.headerService.openControls();
|
||||
}
|
||||
|
||||
onToggleFullscreen(): void {
|
||||
this.headerService.toggleFullscreen();
|
||||
}
|
||||
|
||||
onShowHelp(): void {
|
||||
this.headerService.showShortcutsHelp();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
if (window.history.length <= 2) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
|
||||
@@ -28,8 +28,14 @@ export class ReaderHeaderService {
|
||||
|
||||
private _showControls = new Subject<void>();
|
||||
private _showMetadata = new Subject<void>();
|
||||
private _toggleFullscreen = new Subject<void>();
|
||||
private _showShortcutsHelp = new Subject<void>();
|
||||
private _isFullscreen = new BehaviorSubject<boolean>(false);
|
||||
showControls$ = this._showControls.asObservable();
|
||||
showMetadata$ = this._showMetadata.asObservable();
|
||||
toggleFullscreen$ = this._toggleFullscreen.asObservable();
|
||||
showShortcutsHelp$ = this._showShortcutsHelp.asObservable();
|
||||
fullscreenState$ = this._isFullscreen.asObservable();
|
||||
|
||||
get currentState() {
|
||||
return this.stateService.currentState;
|
||||
@@ -77,6 +83,18 @@ export class ReaderHeaderService {
|
||||
this._showMetadata.next();
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
this._toggleFullscreen.next();
|
||||
}
|
||||
|
||||
setFullscreen(isFullscreen: boolean): void {
|
||||
this._isFullscreen.next(isFullscreen);
|
||||
}
|
||||
|
||||
showShortcutsHelp(): void {
|
||||
this._showShortcutsHelp.next();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.location.back();
|
||||
}
|
||||
@@ -117,6 +135,7 @@ export class ReaderHeaderService {
|
||||
reset(): void {
|
||||
this._forceVisible.next(false);
|
||||
this._isCurrentCfiBookmarked.next(false);
|
||||
this._isFullscreen.next(false);
|
||||
this.bookTitle = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,12 @@
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" [style.width.%]="searchState.progress * 100"></div>
|
||||
</div>
|
||||
<span class="progress-text">Searching... {{ (searchState.progress * 100) | number:'1.0-0' }}%</span>
|
||||
<div class="progress-info">
|
||||
<span class="progress-text">Searching... {{ (searchState.progress * 100) | number:'1.0-0' }}%</span>
|
||||
<button class="cancel-search-btn" (click)="onCancelSearch()" title="Cancel search">
|
||||
<app-reader-icon name="close" [size]="14"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -452,10 +452,36 @@ $transition-normal: 200ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cancel-search-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,4 +128,9 @@ export class ReaderLeftSidebarComponent implements OnInit, OnDestroy {
|
||||
this.searchQuery = '';
|
||||
this.leftSidebarService.clearSearch();
|
||||
}
|
||||
|
||||
onCancelSearch(): void {
|
||||
this.searchQuery = '';
|
||||
this.leftSidebarService.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ export class ReaderLeftSidebarService {
|
||||
this._isOpen.next(false);
|
||||
}
|
||||
|
||||
toggle(tab?: LeftSidebarTab): void {
|
||||
if (this._isOpen.value && (!tab || this._activeTab.value === tab)) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open(tab);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab(tab: LeftSidebarTab): void {
|
||||
this._activeTab.next(tab);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,14 @@ export class ReaderSidebarService {
|
||||
this._isOpen.next(false);
|
||||
}
|
||||
|
||||
toggle(tab?: SidebarTab): void {
|
||||
if (this._isOpen.value && (!tab || this._activeTab.value === tab)) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open(tab);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab(tab: SidebarTab): void {
|
||||
this._activeTab.next(tab);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user