forked from akai/readest
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93d1fe6946 | |||
| 3c52a9e0cb | |||
| 3056a29860 | |||
| bd6738e54c | |||
| 8c3f49de25 | |||
| 0f44fff7cd | |||
| 77bba7bd0c | |||
| d30d1c17ae | |||
| 088c7d2c49 | |||
| d0e6f3ad50 | |||
| 8a83389164 | |||
| 04d07856f5 | |||
| cc8d0ea457 | |||
| 29e6af7768 | |||
| bc70d07749 | |||
| 2db83b8569 | |||
| 2f29295c6c |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.18",
|
||||
"version": "0.9.19",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"Match Case": "匹配大小写",
|
||||
"Match Diacritics": "匹配重音符号",
|
||||
"Match Whole Words": "匹配整个单词",
|
||||
"Maximum Block Size": "内容最大宽度",
|
||||
"Maximum Block Size": "内容最大高度",
|
||||
"Maximum Inline Size": "每栏最大宽度",
|
||||
"Maximum Number of Columns": "分栏数",
|
||||
"Minimum Font Size": "最小字号",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"Match Case": "匹配大小寫",
|
||||
"Match Diacritics": "匹配重音符號",
|
||||
"Match Whole Words": "匹配整個單詞",
|
||||
"Maximum Block Size": "內容最大寬度",
|
||||
"Maximum Block Size": "內容最大高度",
|
||||
"Maximum Inline Size": "每欄最大寬度",
|
||||
"Maximum Number of Columns": "分欄數",
|
||||
"Minimum Font Size": "最小字號",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.19": {
|
||||
"date": "2025-03-07",
|
||||
"notes": [
|
||||
"Support custom CSS for Reader UI",
|
||||
"Initial support of RTL layout for Arabic and Hebrew books",
|
||||
"Various fixes and enhancements on layout and sync"
|
||||
]
|
||||
},
|
||||
"0.9.18": {
|
||||
"date": "2025-02-26",
|
||||
"notes": [
|
||||
|
||||
@@ -10,6 +10,8 @@ extern crate objc;
|
||||
mod menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod traffic_light;
|
||||
#[cfg(target_os = "macos")]
|
||||
use traffic_light::set_traffic_lights;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
@@ -98,6 +100,8 @@ pub fn run() {
|
||||
start_server,
|
||||
download_file,
|
||||
upload_file,
|
||||
#[cfg(target_os = "macos")]
|
||||
set_traffic_lights,
|
||||
#[cfg(desktop)]
|
||||
list_fonts
|
||||
])
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use tauri::Emitter;
|
||||
use tauri::{
|
||||
command,
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime, Window,
|
||||
}; // 0.8
|
||||
Emitter, Runtime, Window,
|
||||
};
|
||||
|
||||
const WINDOW_CONTROL_PAD_X: f64 = 10.0;
|
||||
const WINDOW_CONTROL_PAD_Y: f64 = 22.0;
|
||||
static mut WINDOW_CONTROL_PAD_X: f64 = 10.0;
|
||||
static mut WINDOW_CONTROL_PAD_Y: f64 = 22.0;
|
||||
static mut TRAFFIC_LIGHTS_VISIBLE: bool = true;
|
||||
|
||||
struct UnsafeWindowHandle(*mut std::ffi::c_void);
|
||||
unsafe impl Send for UnsafeWindowHandle {}
|
||||
@@ -30,7 +31,24 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64) {
|
||||
#[command]
|
||||
pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
|
||||
unsafe {
|
||||
TRAFFIC_LIGHTS_VISIBLE = visible;
|
||||
WINDOW_CONTROL_PAD_X = x;
|
||||
WINDOW_CONTROL_PAD_Y = y;
|
||||
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, x: f64, y: f64) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
let ns_window = ns_window_handle.0 as cocoa::base::id;
|
||||
@@ -45,7 +63,10 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64)
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_frame_height = button_height + y;
|
||||
if !visible {
|
||||
title_bar_frame_height = 0.0;
|
||||
}
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
|
||||
@@ -77,11 +98,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
use std::ffi::c_void;
|
||||
|
||||
// Do the initial positioning
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
unsafe {
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure they stay in place while resizing the window.
|
||||
fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
|
||||
@@ -127,6 +151,7 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
#[cfg(target_os = "macos")]
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
@@ -258,6 +283,7 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
let id = state.window.ns_window().expect("Failed to emit event") as id;
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function AuthPage() {
|
||||
};
|
||||
|
||||
const handleOAuthUrl = async (url: string) => {
|
||||
console.log('Received OAuth URL:', url);
|
||||
console.log('Handle OAuth URL:', url);
|
||||
const hashMatch = url.match(/#(.*)/);
|
||||
if (hashMatch) {
|
||||
const hash = hashMatch[1];
|
||||
|
||||
@@ -143,7 +143,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8',
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
|
||||
)}
|
||||
>
|
||||
{currentBookshelfItems.map((item, index) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
@@ -10,8 +10,8 @@ import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
@@ -33,7 +33,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize20 = useResponsiveSize(20);
|
||||
@@ -42,6 +48,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onToggleSelectMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const isInGroupView = !!searchParams?.get('group');
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
@@ -28,6 +28,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
import { useBooksSync } from './hooks/useBooksSync';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { tauriQuitApp } from '@/utils/window';
|
||||
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
@@ -38,7 +39,12 @@ import Bookshelf from './components/Bookshelf';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const LibraryPageWithSearchParams = () => {
|
||||
const searchParams = useSearchParams();
|
||||
return <LibraryPageContent searchParams={searchParams} />;
|
||||
};
|
||||
|
||||
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
@@ -47,7 +53,7 @@ const LibraryPage = () => {
|
||||
updateBook,
|
||||
setLibrary,
|
||||
checkOpenWithBooks,
|
||||
clearOpenWithBooks,
|
||||
setCheckOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme } = useTheme();
|
||||
@@ -63,6 +69,8 @@ const LibraryPage = () => {
|
||||
const demoBooks = useDemoBooks();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOpenWithBooks();
|
||||
|
||||
const { pullLibrary, pushLibrary } = useBooksSync({
|
||||
onSyncStart: () => setLoading(true),
|
||||
onSyncEnd: () => setLoading(false),
|
||||
@@ -113,7 +121,9 @@ const LibraryPage = () => {
|
||||
|
||||
console.log('Opening books:', bookIds);
|
||||
if (bookIds.length > 0) {
|
||||
navigateToReader(router, bookIds);
|
||||
setTimeout(() => {
|
||||
navigateToReader(router, bookIds);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -148,7 +158,7 @@ const LibraryPage = () => {
|
||||
if (checkOpenWithBooks && isTauriAppPlatform()) {
|
||||
await handleOpenWithBooks(appService, libraryBooks);
|
||||
} else {
|
||||
clearOpenWithBooks();
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
|
||||
@@ -163,7 +173,7 @@ const LibraryPage = () => {
|
||||
if (openWithFiles.length > 0) {
|
||||
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
|
||||
} else {
|
||||
clearOpenWithBooks();
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
};
|
||||
@@ -171,10 +181,11 @@ const LibraryPage = () => {
|
||||
initLogin();
|
||||
initLibrary();
|
||||
return () => {
|
||||
clearOpenWithBooks();
|
||||
setCheckOpenWithBooks(false);
|
||||
isInitiating.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demoBooks.length > 0 && libraryLoaded) {
|
||||
@@ -449,4 +460,18 @@ const LibraryPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryPage = () => {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LibraryPageWithSearchParams />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryPage;
|
||||
|
||||
@@ -85,14 +85,20 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
<HintInfo bookKey={bookKey} gapRight={marginGap} />
|
||||
<PageInfoView
|
||||
bookFormat={book.format}
|
||||
section={section ?? null}
|
||||
pageinfo={pageinfo ?? null}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
gapRight={marginGap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Annotator bookKey={bookKey} />
|
||||
<FooterBar bookKey={bookKey} pageinfo={pageinfo} isHoveredAnim={false} />
|
||||
<FooterBar
|
||||
bookKey={bookKey}
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
isHoveredAnim={false}
|
||||
/>
|
||||
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
||||
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||
import { getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import {
|
||||
handleKeydown,
|
||||
handleMousedown,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -36,6 +37,7 @@ const FoliateViewer: React.FC<{
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode } = useTheme();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
useEffect(() => {
|
||||
@@ -43,6 +45,7 @@ const FoliateViewer: React.FC<{
|
||||
return () => clearTimeout(timer);
|
||||
}, [toastMessage]);
|
||||
|
||||
useUICSS(bookKey, viewSettings);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
|
||||
@@ -58,7 +61,9 @@ const FoliateViewer: React.FC<{
|
||||
if (detail.doc) {
|
||||
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.vertical = writingDir?.vertical || false;
|
||||
viewSettings.vertical =
|
||||
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
||||
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
|
||||
handleScrollbarAutoHide(detail.doc);
|
||||
@@ -133,31 +138,34 @@ const FoliateViewer: React.FC<{
|
||||
console.log('Opening book', bookKey);
|
||||
await import('foliate-js/view.js');
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
view.id = `foliate-view-${bookKey}`;
|
||||
document.body.append(view);
|
||||
containerRef.current?.appendChild(view);
|
||||
|
||||
const writingMode = viewSettings.writingMode;
|
||||
if (writingMode) {
|
||||
const settingsDir = getBookDirFromWritingMode(writingMode);
|
||||
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
||||
if (settingsDir !== 'auto') {
|
||||
bookDoc.dir = settingsDir;
|
||||
} else if (languageDir !== 'auto') {
|
||||
bookDoc.dir = languageDir;
|
||||
}
|
||||
}
|
||||
|
||||
await view.open(bookDoc);
|
||||
// make sure we can listen renderer events after opening book
|
||||
viewRef.current = view;
|
||||
setFoliateView(bookKey, view);
|
||||
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
|
||||
const writingMode = viewSettings.writingMode;
|
||||
if (writingMode) {
|
||||
view.book.dir = getBookDirFromWritingMode(writingMode);
|
||||
}
|
||||
|
||||
const isScrolled = viewSettings.scrolled!;
|
||||
const marginPx = viewSettings.marginPx!;
|
||||
const gapPercent = viewSettings.gapPercent!;
|
||||
const animated = viewSettings.animated!;
|
||||
const maxColumnCount = viewSettings.maxColumnCount!;
|
||||
const maxInlineSize =
|
||||
maxColumnCount === 1 || isScrolled
|
||||
? ONE_COLUMN_MAX_INLINE_SIZE
|
||||
: viewSettings.maxInlineSize!;
|
||||
const maxInlineSize = getMaxInlineSize(viewSettings);
|
||||
const maxBlockSize = viewSettings.maxBlockSize!;
|
||||
if (animated) {
|
||||
view.renderer.setAttribute('animated', '');
|
||||
|
||||
@@ -9,21 +9,32 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { PageInfo } from '@/types/book';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
interface FooterBarProps {
|
||||
bookKey: string;
|
||||
pageinfo: { current: number; total: number } | undefined;
|
||||
bookFormat: string;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
isHoveredAnim: boolean;
|
||||
}
|
||||
|
||||
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
|
||||
const FooterBar: React.FC<FooterBarProps> = ({
|
||||
bookKey,
|
||||
bookFormat,
|
||||
section,
|
||||
pageinfo,
|
||||
isHoveredAnim,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
|
||||
useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const handleProgressChange = (event: React.ChangeEvent) => {
|
||||
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
|
||||
@@ -56,8 +67,9 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
|
||||
}
|
||||
};
|
||||
|
||||
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
|
||||
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
|
||||
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
|
||||
const progressValid = !!progressInfo;
|
||||
const progressFraction = progressValid ? (progressInfo!.current + 1) / progressInfo!.total : 0;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -69,11 +81,16 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
|
||||
)}
|
||||
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
|
||||
onMouseEnter={() => setHoveredBookKey(bookKey)}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
>
|
||||
<div className='hidden sm:flex'>
|
||||
<Button icon={<RiArrowLeftWideLine />} onClick={handleGoPrev} tooltip={_('Go Left')} />
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
|
||||
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<RiArrowGoBackLine />}
|
||||
@@ -88,19 +105,23 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
|
||||
disabled={!view?.history.canGoForward}
|
||||
/>
|
||||
<span className='mx-2 text-center text-sm'>
|
||||
{pageinfoValid ? `${Math.round(progressFraction * 100)}%` : ''}
|
||||
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
min={0}
|
||||
max={100}
|
||||
value={pageinfoValid ? progressFraction * 100 : 0}
|
||||
value={progressValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) => handleProgressChange(e)}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<div className='hidden sm:flex'>
|
||||
<Button icon={<RiArrowRightWideLine />} onClick={handleGoNext} tooltip={_('Go Right')} />
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
|
||||
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SidebarToggler from './SidebarToggler';
|
||||
@@ -34,7 +34,13 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { hoveredBookKey, setHoveredBookKey, bookKeys } = useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
@@ -45,6 +51,18 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
if (!isOpen) setHoveredBookKey('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(isSideBarVisible);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSideBarVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
|
||||
interface SectionInfoProps {
|
||||
bookKey: string;
|
||||
@@ -11,7 +11,7 @@ interface SectionInfoProps {
|
||||
|
||||
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
|
||||
const hintTimeout = useRef(2000);
|
||||
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { PageInfo } from '@/types/book';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookFormat: string;
|
||||
section: PageInfo | null;
|
||||
pageinfo: PageInfo | null;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
gapRight: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import ReaderContent from './ReaderContent';
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const { getVisibleLibrary, setLibrary } = useLibraryStore();
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
const { updateAppTheme } = useTheme();
|
||||
@@ -38,7 +38,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
library.length > 0 &&
|
||||
getVisibleLibrary().length > 0 &&
|
||||
settings.globalReadSettings && (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
|
||||
interface SectionInfoProps {
|
||||
section?: string;
|
||||
@@ -11,7 +11,7 @@ interface SectionInfoProps {
|
||||
|
||||
const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -5,17 +5,13 @@ import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
MAX_ZOOM_LEVEL,
|
||||
MIN_ZOOM_LEVEL,
|
||||
ONE_COLUMN_MAX_INLINE_SIZE,
|
||||
ZOOM_STEP,
|
||||
} from '@/services/constants';
|
||||
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme, ThemeMode } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
|
||||
interface ViewMenuProps {
|
||||
bookKey: string;
|
||||
@@ -65,7 +61,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
|
||||
getView(bookKey)?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${viewSettings.maxColumnCount === 1 || isScrolledMode ? ONE_COLUMN_MAX_INLINE_SIZE : viewSettings.maxInlineSize}px`,
|
||||
`${getMaxInlineSize(viewSettings)}px`,
|
||||
);
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface AnnotationPopupProps {
|
||||
dir: 'ltr' | 'rtl';
|
||||
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
@@ -22,6 +23,7 @@ const OPTIONS_HEIGHT_PIX = 28;
|
||||
const OPTIONS_PADDING_PIX = 16;
|
||||
|
||||
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
dir,
|
||||
buttons,
|
||||
position,
|
||||
trianglePosition,
|
||||
@@ -35,7 +37,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
|
||||
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
|
||||
return (
|
||||
<div>
|
||||
<div dir={dir}>
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
|
||||
@@ -76,7 +76,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const dictPopupHeight = Math.min(300, maxHeight);
|
||||
const transPopupWidth = Math.min(480, maxWidth);
|
||||
const transPopupHeight = Math.min(360, maxHeight);
|
||||
const annotPopupWidth = useResponsiveSize(280);
|
||||
const annotPopupWidth = useResponsiveSize(300);
|
||||
const annotPopupHeight = useResponsiveSize(44);
|
||||
const androidSelectionHandlerHeight = 8;
|
||||
|
||||
@@ -487,6 +487,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
)}
|
||||
{showAnnotPopup && trianglePosition && annotPopupPosition && (
|
||||
<AnnotationPopup
|
||||
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
|
||||
buttons={buttons}
|
||||
position={annotPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
|
||||
@@ -17,9 +17,10 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { FONT_ENUM_SUPPORTED_OS_PLATFORMS, getSysFontsList } from '@/utils/font';
|
||||
import { getSysFontsList } from '@/utils/font';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
interface FontFaceProps {
|
||||
@@ -60,6 +61,7 @@ const FontFace = ({
|
||||
|
||||
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
@@ -109,7 +111,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform() && FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
|
||||
if (isTauriAppPlatform() && appService?.hasSysFontsList) {
|
||||
getSysFontsList().then((fonts) => {
|
||||
setSysFonts(fonts);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdOutlineAutoMode } from 'react-icons/md';
|
||||
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
|
||||
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
|
||||
import { TbTextDirectionRtl } from 'react-icons/tb';
|
||||
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
@@ -147,10 +148,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setAttribute('max-column-count', maxColumnCount);
|
||||
view?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${maxColumnCount === 1 || viewSettings.scrolled ? ONE_COLUMN_MAX_INLINE_SIZE : maxInlineSize}px`,
|
||||
);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxColumnCount]);
|
||||
|
||||
@@ -161,10 +159,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.maxInlineSize = maxInlineSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${maxColumnCount === 1 || viewSettings.scrolled ? ONE_COLUMN_MAX_INLINE_SIZE : maxInlineSize}px`,
|
||||
);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxInlineSize]);
|
||||
|
||||
@@ -181,12 +176,19 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
useEffect(() => {
|
||||
// global settings are not supported for writing mode
|
||||
const prevWritingMode = viewSettings.writingMode;
|
||||
viewSettings.writingMode = writingMode;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (view) {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view.book.dir = getBookDirFromWritingMode(writingMode);
|
||||
}
|
||||
if (
|
||||
prevWritingMode !== writingMode &&
|
||||
(writingMode === 'horizontal-rl' || prevWritingMode === 'horizontal-rl')
|
||||
) {
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writingMode]);
|
||||
|
||||
@@ -202,11 +204,12 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [overrideLayout]);
|
||||
|
||||
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
|
||||
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
|
||||
const mightBeRTLBook =
|
||||
langCode === 'zh' || langCode === 'ja' || langCode === 'ko' || langCode === '';
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
{isCJKBook && (
|
||||
{mightBeRTLBook && (
|
||||
<div className='w-full'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
@@ -234,7 +237,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdOutlineTextRotationDown />
|
||||
<MdTextRotateVertical />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-rl')}
|
||||
>
|
||||
<TbTextDirectionRtl />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +268,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.5}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Line Spacing')}
|
||||
value={lineHeight}
|
||||
onChange={setLineHeight}
|
||||
@@ -265,7 +276,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Word Spacing')}
|
||||
value={wordSpacing}
|
||||
onChange={setWordSpacing}
|
||||
@@ -274,7 +284,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.5}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Letter Spacing')}
|
||||
value={letterSpacing}
|
||||
onChange={setLetterSpacing}
|
||||
@@ -283,7 +292,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Text Indent')}
|
||||
value={textIndent}
|
||||
onChange={setTextIndent}
|
||||
@@ -353,6 +361,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
label={_('Maximum Inline Size')}
|
||||
value={maxInlineSize}
|
||||
onChange={setMaxInlineSize}
|
||||
disabled={maxColumnCount === 1 || viewSettings.scrolled}
|
||||
min={500}
|
||||
max={9999}
|
||||
step={100}
|
||||
|
||||
@@ -52,7 +52,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setDraftStylesheet(formattedCSS);
|
||||
setDraftStylesheetSaved(true);
|
||||
viewSettings.userStylesheet = formattedCSS;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.userStylesheet = formattedCSS;
|
||||
|
||||
@@ -9,6 +9,7 @@ interface NumberInputProps {
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const numberStep = step || 1;
|
||||
@@ -74,13 +76,13 @@ const NumberInput: React.FC<NumberInputProps> = ({
|
||||
/>
|
||||
<button
|
||||
onClick={decrement}
|
||||
className={`btn btn-circle btn-sm ${value === min ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
className={`btn btn-circle btn-sm ${value <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
<FiMinus className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={increment}
|
||||
className={`btn btn-circle btn-sm ${value === max ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
<FiPlus className='h-4 w-4' />
|
||||
</button>
|
||||
|
||||
@@ -64,7 +64,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName='sm:w-1/2 sm:min-w-[480px] sm:h-[65%]'
|
||||
boxClassName='sm:min-w-[520px]'
|
||||
header={
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<button
|
||||
@@ -76,12 +76,12 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
>
|
||||
<MdArrowBackIosNew />
|
||||
</button>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center justify-around pl-4'>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center pl-4 gap-2'>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content h-8 min-h-8',
|
||||
'btn btn-ghost text-base-content btn-sm',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => setActivePanel(tab)}
|
||||
|
||||
@@ -23,7 +23,7 @@ const BookCard = ({ book }: { book: Book }) => {
|
||||
alt={_('Book Cover')}
|
||||
width={56}
|
||||
height={80}
|
||||
className='mr-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md'
|
||||
className='me-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface BookMenuProps {
|
||||
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const { library } = useLibraryStore();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
const handleParallelView = (id: string) => {
|
||||
openParallelView(id);
|
||||
@@ -48,26 +48,28 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
>
|
||||
<MenuItem label={_('Parallel Read')} noIcon>
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{library.slice(0, 20).map((book) => (
|
||||
<MenuItem
|
||||
key={book.hash}
|
||||
icon={
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={56}
|
||||
height={80}
|
||||
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={book.title}
|
||||
labelClass='max-w-36'
|
||||
onClick={() => handleParallelView(book.hash)}
|
||||
/>
|
||||
))}
|
||||
{getVisibleLibrary()
|
||||
.slice(0, 20)
|
||||
.map((book) => (
|
||||
<MenuItem
|
||||
key={book.hash}
|
||||
icon={
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={56}
|
||||
height={80}
|
||||
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={book.title}
|
||||
labelClass='max-w-36'
|
||||
onClick={() => handleParallelView(book.hash)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
|
||||
@@ -5,8 +5,8 @@ import { FiSearch } from 'react-icons/fi';
|
||||
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import BookMenu from './BookMenu';
|
||||
|
||||
@@ -18,16 +18,18 @@ const SidebarHeader: React.FC<{
|
||||
onTogglePin: () => void;
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'sidebar-header flex h-11 items-center justify-between pr-2',
|
||||
isTrafficLightVisible ? 'pl-20' : 'pl-1.5',
|
||||
'sidebar-header flex h-11 items-center justify-between pe-2',
|
||||
isTrafficLightVisible ? 'pl-20' : 'ps-1.5',
|
||||
)}
|
||||
dir='ltr'
|
||||
>
|
||||
<div className='flex items-center gap-x-8'>
|
||||
<button
|
||||
|
||||
@@ -32,7 +32,7 @@ const SideBar: React.FC<{
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -162,6 +162,7 @@ const SideBar: React.FC<{
|
||||
|
||||
if (!sideBarBookKey) return null;
|
||||
|
||||
const viewSettings = getViewSettings(sideBarBookKey);
|
||||
const bookData = getBookData(sideBarBookKey);
|
||||
if (!bookData || !bookData.book || !bookData.bookDoc) {
|
||||
return null;
|
||||
@@ -178,6 +179,7 @@ const SideBar: React.FC<{
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
|
||||
!isSideBarPinned && 'shadow-2xl',
|
||||
)}
|
||||
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
|
||||
style={{
|
||||
width: `${sideBarWidth}`,
|
||||
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
|
||||
|
||||
@@ -15,10 +15,11 @@ const TabNavigation: React.FC<{
|
||||
return (
|
||||
<div
|
||||
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
|
||||
dir='ltr'
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-300 absolute bottom-1.5 left-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'bg-base-300 absolute bottom-1.5 start-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'transform transition-transform duration-300',
|
||||
activeTab === 'toc' && 'translate-x-0',
|
||||
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
@@ -12,12 +13,6 @@ export const useNotesSync = (bookKey: string) => {
|
||||
const config = getConfig(bookKey);
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
syncNotes([], bookHash, 'pull');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -57,13 +52,25 @@ export const useNotesSync = (bookKey: string) => {
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncedNotes?.length && config?.location) {
|
||||
const processNewNote = (note: BookNote) => {
|
||||
const oldNotes = config?.booknotes ?? [];
|
||||
const existingNote = oldNotes.find((oldNote) => oldNote.id === note.id);
|
||||
if (existingNote) {
|
||||
if (existingNote.updatedAt < note.updatedAt) {
|
||||
return { ...existingNote, ...note };
|
||||
} else {
|
||||
return { ...note, ...existingNote };
|
||||
}
|
||||
}
|
||||
return note;
|
||||
};
|
||||
if (syncedNotes?.length && config) {
|
||||
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
|
||||
if (!newNotes.length) return;
|
||||
const oldNotes = config.booknotes ?? [];
|
||||
const mergedNotes = [
|
||||
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
|
||||
...newNotes,
|
||||
...newNotes.map(processNewNote),
|
||||
];
|
||||
setConfig(bookKey, { ...config, booknotes: mergedNotes });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
|
||||
export const useProgressAutoSave = (bookKey: string) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { envConfig } = useEnv();
|
||||
const { getConfig, saveConfig } = useBookDataStore();
|
||||
const { getProgress } = useReaderStore();
|
||||
const progress = getProgress(bookKey);
|
||||
@@ -22,9 +22,6 @@ export const useProgressAutoSave = (bookKey: string) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// FIXME: On Android and iOS we need a better way to be notified
|
||||
// when the app is about to go in background
|
||||
if (!appService?.isMobile || !progress) return;
|
||||
saveBookConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress, bookKey]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { hasUpdater } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
@@ -13,6 +14,7 @@ export default function Page() {
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
useTheme();
|
||||
useOpenWithBooks();
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
|
||||
@@ -110,7 +110,10 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
|
||||
'h-full max-h-full w-full max-w-full sm:w-[65%] sm:max-w-[600px]',
|
||||
'h-full max-h-full w-full max-w-full',
|
||||
window.innerWidth < window.innerHeight
|
||||
? 'sm:h-[50%] sm:w-3/4'
|
||||
: 'sm:h-[65%] sm:w-1/2 sm:max-w-[600px]',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
|
||||
boxClassName,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
|
||||
export function useOpenWithBooks() {
|
||||
const router = useRouter();
|
||||
const { setCheckOpenWithBooks } = useLibraryStore();
|
||||
const listenedOpenWithBooks = useRef(false);
|
||||
|
||||
const handleOpenWithFileUrl = (url: string) => {
|
||||
let filePath = url;
|
||||
if (filePath.startsWith('file://')) {
|
||||
filePath = decodeURI(filePath.replace('file://', ''));
|
||||
}
|
||||
if (filePath.startsWith('/')) {
|
||||
window.OPEN_WITH_FILES = [filePath];
|
||||
setCheckOpenWithBooks(true);
|
||||
navigateToLibrary(router, `reload=${Date.now()}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
if (listenedOpenWithBooks.current) return;
|
||||
listenedOpenWithBooks.current = true;
|
||||
|
||||
const listenOpenWithFiles = async () => {
|
||||
return await onOpenUrl((urls) => {
|
||||
urls.forEach((url) => {
|
||||
handleOpenWithFileUrl(url);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const unlisten = listenOpenWithFiles();
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
export const useScreenWakeLock = (lock: boolean) => {
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
const unlistenOnFocusChangedRef = useRef<Promise<() => void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const requestWakeLock = async () => {
|
||||
@@ -20,20 +23,50 @@ export const useScreenWakeLock = (lock: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
requestWakeLock();
|
||||
} else if (wakeLockRef.current) {
|
||||
wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
console.log('Wake lock released');
|
||||
}
|
||||
|
||||
return () => {
|
||||
const releaseWakeLock = () => {
|
||||
if (wakeLockRef.current) {
|
||||
wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
console.log('Wake lock released');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
releaseWakeLock();
|
||||
} else {
|
||||
requestWakeLock();
|
||||
}
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
requestWakeLock();
|
||||
} else if (wakeLockRef.current) {
|
||||
releaseWakeLock();
|
||||
}
|
||||
|
||||
if (isWebAppPlatform() && lock) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
} else if (isTauriAppPlatform() && lock) {
|
||||
unlistenOnFocusChangedRef.current = getCurrentWindow().onFocusChanged(
|
||||
({ payload: focused }) => {
|
||||
if (focused) {
|
||||
requestWakeLock();
|
||||
} else {
|
||||
releaseWakeLock();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
releaseWakeLock();
|
||||
if (isWebAppPlatform() && lock) {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (unlistenOnFocusChangedRef.current) {
|
||||
unlistenOnFocusChangedRef.current.then((f) => f());
|
||||
}
|
||||
};
|
||||
}, [lock]);
|
||||
};
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
|
||||
/**
|
||||
* Custom hook to get the visibility of the traffic light (window control buttons on macOS)
|
||||
* based on the fullscreen state of the application window.
|
||||
*
|
||||
* @returns {Object} An object containing:
|
||||
* - `isTrafficLightVisible` (boolean): A state indicating whether the traffic light is visible.
|
||||
*/
|
||||
const useTrafficLight = () => {
|
||||
const { appService } = useEnv();
|
||||
const [isTrafficLightVisible, setVisible] = useState(appService?.hasTrafficLight ?? false);
|
||||
let unlistenEnterFullScreen: () => void;
|
||||
let unlistenExitFullScreen: () => void;
|
||||
|
||||
const handleSwitchFullScreen = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
const isFullscreen = await currentWindow.isFullscreen();
|
||||
if (appService?.hasTrafficLight) setVisible(!isFullscreen);
|
||||
|
||||
unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
|
||||
if (appService?.hasTrafficLight) setVisible(false);
|
||||
});
|
||||
|
||||
unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
|
||||
if (appService?.hasTrafficLight) setVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
handleSwitchFullScreen();
|
||||
|
||||
return () => {
|
||||
unlistenEnterFullScreen?.();
|
||||
unlistenExitFullScreen?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { isTrafficLightVisible };
|
||||
};
|
||||
|
||||
export default useTrafficLight;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// This hook allows you to inject custom CSS into the reader UI.
|
||||
// Note that the book content is rendered in an iframe, so UI CSS won't affect book rendering.
|
||||
export const useUICSS = (bookKey: string, viewSettings: ViewSettings) => {
|
||||
const [styleElement, setStyleElement] = useState<HTMLStyleElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewSettings) return;
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
|
||||
const rawCSS = viewSettings.userStylesheet;
|
||||
const newStyleEl = document.createElement('style');
|
||||
newStyleEl.textContent = rawCSS.replace('foliate-view', `#foliate-view-${bookKey}`);
|
||||
document.head.appendChild(newStyleEl);
|
||||
setStyleElement(newStyleEl);
|
||||
|
||||
return () => {
|
||||
newStyleEl.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewSettings]);
|
||||
};
|
||||
@@ -30,19 +30,26 @@ const getUserAndToken = async (req: NextRequest) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
if (error?.message === 'fetch failed') {
|
||||
return { error: 'Network error' };
|
||||
} else if (error || !user) {
|
||||
return { error: 'Not authenticated' };
|
||||
}
|
||||
return { user, token };
|
||||
} catch {
|
||||
return { error: 'Network error' };
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
const { user, token, error } = await getUserAndToken(req);
|
||||
if (!user || !token || error) {
|
||||
return NextResponse.json({ error: error || 'Unknown error' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
@@ -114,9 +121,9 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
const { user, token, error } = await getUserAndToken(req);
|
||||
if (!user || !token || error) {
|
||||
return NextResponse.json({ error: error || 'Unknown error' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
@@ -173,8 +180,6 @@ export async function POST(req: NextRequest) {
|
||||
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
|
||||
|
||||
if (clientIsNewer) {
|
||||
// use server updated_at for updated records
|
||||
dbRec.updated_at = new Date().toISOString();
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from(table)
|
||||
.update(dbRec)
|
||||
|
||||
@@ -53,6 +53,7 @@ export abstract class BaseAppService implements AppService {
|
||||
abstract hasRoundedWindow: boolean;
|
||||
abstract hasSafeAreaInset: boolean;
|
||||
abstract hasHaptics: boolean;
|
||||
abstract hasSysFontsList: boolean;
|
||||
|
||||
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
|
||||
abstract getCoverImageUrl(book: Book): string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/types/book';
|
||||
import { ReadSettings, SystemSettings } from '@/types/settings';
|
||||
import { UserStorageQuota } from '@/types/user';
|
||||
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
|
||||
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
@@ -62,11 +63,12 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
scrolled: false,
|
||||
disableClick: false,
|
||||
maxColumnCount: 2,
|
||||
maxInlineSize: 720,
|
||||
maxBlockSize: 1440,
|
||||
maxInlineSize: getDefaultMaxInlineSize(),
|
||||
maxBlockSize: getDefaultMaxBlockSize(),
|
||||
animated: false,
|
||||
writingMode: 'auto',
|
||||
vertical: false,
|
||||
rtl: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
@@ -381,8 +383,6 @@ export const ANDROID_FONTS = [
|
||||
'XiHeiti',
|
||||
];
|
||||
|
||||
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
||||
|
||||
export const BOOK_IDS_SEPARATOR = '+';
|
||||
|
||||
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
||||
|
||||
@@ -127,6 +127,7 @@ export class NativeAppService extends BaseAppService {
|
||||
hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
|
||||
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
|
||||
return resolvePath(fp, base);
|
||||
|
||||
@@ -189,6 +189,7 @@ export class WebAppService extends BaseAppService {
|
||||
hasRoundedWindow = false;
|
||||
hasSafeAreaInset = isPWA();
|
||||
hasHaptics = false;
|
||||
hasSysFontsList = false;
|
||||
|
||||
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
|
||||
return resolvePath(fp, base);
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Book } from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
|
||||
interface LibraryState {
|
||||
library: Book[];
|
||||
library: Book[]; // might contain deleted books
|
||||
checkOpenWithBooks: boolean;
|
||||
clearOpenWithBooks: () => void;
|
||||
getVisibleLibrary: () => Book[];
|
||||
setCheckOpenWithBooks: (check: boolean) => void;
|
||||
setLibrary: (books: Book[]) => void;
|
||||
updateBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
}
|
||||
@@ -13,7 +14,8 @@ interface LibraryState {
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
library: [],
|
||||
checkOpenWithBooks: true,
|
||||
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
|
||||
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
|
||||
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
updateBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
const WINDOW_CONTROL_PAD_X = 10.0;
|
||||
const WINDOW_CONTROL_PAD_Y = 22.0;
|
||||
|
||||
interface TrafficLightState {
|
||||
appService?: AppService;
|
||||
isTrafficLightVisible: boolean;
|
||||
shouldShowTrafficLight: boolean;
|
||||
initializeTrafficLightStore: (appService: AppService) => void;
|
||||
setTrafficLightVisibility: (visible: boolean) => void;
|
||||
initializeTrafficLightListeners: () => Promise<void>;
|
||||
cleanupTrafficLightListeners: () => void;
|
||||
unlistenEnterFullScreen?: () => void;
|
||||
unlistenExitFullScreen?: () => void;
|
||||
}
|
||||
|
||||
export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
|
||||
return {
|
||||
appService: undefined,
|
||||
isTrafficLightVisible: false,
|
||||
shouldShowTrafficLight: false,
|
||||
|
||||
initializeTrafficLightStore: (appService: AppService) => {
|
||||
set({
|
||||
appService,
|
||||
isTrafficLightVisible: appService.hasTrafficLight,
|
||||
shouldShowTrafficLight: appService.hasTrafficLight,
|
||||
});
|
||||
},
|
||||
|
||||
setTrafficLightVisibility: async (visible: boolean) => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
const isFullscreen = await currentWindow.isFullscreen();
|
||||
set({ isTrafficLightVisible: !isFullscreen && visible, shouldShowTrafficLight: visible });
|
||||
invoke('set_traffic_lights', {
|
||||
visible: visible,
|
||||
x: WINDOW_CONTROL_PAD_X,
|
||||
y: WINDOW_CONTROL_PAD_Y,
|
||||
});
|
||||
},
|
||||
|
||||
initializeTrafficLightListeners: async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
|
||||
const unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
|
||||
set({ isTrafficLightVisible: false });
|
||||
});
|
||||
|
||||
const unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
|
||||
const { shouldShowTrafficLight } = get();
|
||||
set({ isTrafficLightVisible: shouldShowTrafficLight });
|
||||
});
|
||||
|
||||
set({ unlistenEnterFullScreen, unlistenExitFullScreen });
|
||||
},
|
||||
|
||||
cleanupTrafficLightListeners: () => {
|
||||
const { unlistenEnterFullScreen, unlistenExitFullScreen } = get();
|
||||
if (unlistenEnterFullScreen) unlistenEnterFullScreen();
|
||||
if (unlistenExitFullScreen) unlistenExitFullScreen();
|
||||
set({ unlistenEnterFullScreen: undefined, unlistenExitFullScreen: undefined });
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
--foreground: #171717;
|
||||
border-radius: 10px;
|
||||
scrollbar-gutter: auto !important;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -35,6 +36,7 @@ body {
|
||||
sans-serif;
|
||||
border-radius: 10px;
|
||||
background-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -262,3 +264,7 @@ foliate-view {
|
||||
font-size: 0.75em !important;
|
||||
line-height: 1em !important;
|
||||
}
|
||||
|
||||
.direction-rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface BookLayout {
|
||||
animated: boolean;
|
||||
writingMode: WritingMode;
|
||||
vertical: boolean;
|
||||
rtl: boolean;
|
||||
}
|
||||
|
||||
export interface BookStyle {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface AppService {
|
||||
hasRoundedWindow: boolean;
|
||||
hasSafeAreaInset: boolean;
|
||||
hasHaptics: boolean;
|
||||
hasSysFontsList: boolean;
|
||||
isMobile: boolean;
|
||||
isAppDataSandbox: boolean;
|
||||
isAndroidApp: boolean;
|
||||
|
||||
@@ -57,9 +57,9 @@ export const listFormater = (narrow = false, lang = userLang) => {
|
||||
export const getBookLangCode = (lang: string | string[] | undefined) => {
|
||||
try {
|
||||
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
|
||||
return bookLang ? bookLang.split('-')[0]! : 'en';
|
||||
return bookLang ? bookLang.split('-')[0]! : '';
|
||||
} catch {
|
||||
return 'en';
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const formatAuthors = (
|
||||
contributors: string | Contributor | [string | Contributor],
|
||||
bookLang?: string | string[],
|
||||
) => {
|
||||
const langCode = getBookLangCode(bookLang);
|
||||
const langCode = getBookLangCode(bookLang) || 'en';
|
||||
return Array.isArray(contributors)
|
||||
? listFormater(langCode === 'zh', langCode).format(
|
||||
contributors.map((contributor) =>
|
||||
@@ -90,6 +90,10 @@ export const formatLanguage = (lang: string | string[] | undefined) => {
|
||||
return Array.isArray(lang) ? lang.join(', ') : lang;
|
||||
};
|
||||
|
||||
export const primaryLanguage = (lang: string | string[] | undefined) => {
|
||||
return Array.isArray(lang) ? lang[0] : lang;
|
||||
};
|
||||
|
||||
export const formatDate = (date: string | number | Date | undefined) => {
|
||||
if (!date) return;
|
||||
try {
|
||||
@@ -131,3 +135,11 @@ export const getBookDirFromWritingMode = (writingMode: WritingMode) => {
|
||||
return 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
export const getBookDirFromLanguage = (language: string | string[] | undefined) => {
|
||||
const lang = primaryLanguage(language);
|
||||
if (!lang) return 'auto';
|
||||
const rtlLanguages = new Set(['ar', 'he', 'fa', 'ur', 'dv', 'ps', 'sd', 'yi']);
|
||||
const primaryLang = lang.split('-')[0]!.toLowerCase();
|
||||
return rtlLanguages.has(primaryLang) ? 'rtl' : 'auto';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ViewSettings } from '@/types/book';
|
||||
|
||||
export const getMaxInlineSize = (viewSettings: ViewSettings) => {
|
||||
const isScrolled = viewSettings.scrolled!;
|
||||
const maxColumnCount = viewSettings.maxColumnCount!;
|
||||
const screenWidth = window.innerWidth;
|
||||
|
||||
return maxColumnCount === 1 || isScrolled ? screenWidth : viewSettings.maxInlineSize!;
|
||||
};
|
||||
|
||||
export const getDefaultMaxInlineSize = () => {
|
||||
if (typeof window === 'undefined') return 720;
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const screenHeight = window.innerHeight;
|
||||
return screenWidth < screenHeight ? Math.max(screenWidth, 720) : 720;
|
||||
};
|
||||
|
||||
export const getDefaultMaxBlockSize = () => {
|
||||
if (typeof window === 'undefined') return 1440;
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const screenHeight = window.innerHeight;
|
||||
return Math.max(screenWidth, screenHeight, 1440);
|
||||
};
|
||||
@@ -178,11 +178,11 @@ const getLayoutStyles = (
|
||||
background: var(--background-set, none);
|
||||
}
|
||||
body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
border-color: currentColor !important;
|
||||
${bg === '#ffffff' ? '' : `color: inherit;`}
|
||||
${bg === '#ffffff' ? '' : `background-color: ${bg} !important;`}
|
||||
}
|
||||
body {
|
||||
overflow: unset;
|
||||
zoom: ${zoomLevel};
|
||||
}
|
||||
svg, img {
|
||||
@@ -227,6 +227,10 @@ const getLayoutStyles = (
|
||||
.calibre {
|
||||
color: unset;
|
||||
}
|
||||
|
||||
.chapterHeader {
|
||||
border-color: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
export const getFootnoteStyles = () => `
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 601d70dbe4...313818eb9d
Reference in New Issue
Block a user