Support reading progress synchronization across platforms (#34)
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PostgrestError } from '@supabase/supabase-js';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { BookDataRecord } from '@/types/book';
|
||||
import { transformBookConfigToDB } from '@/utils/transform';
|
||||
import { transformBookNoteToDB } from '@/utils/transform';
|
||||
import { transformBookToDB } from '@/utils/transform';
|
||||
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
|
||||
|
||||
const transformsToDB = {
|
||||
books: transformBookToDB,
|
||||
book_notes: transformBookNoteToDB,
|
||||
book_configs: transformBookConfigToDB,
|
||||
};
|
||||
|
||||
const DBSyncTypeMap = {
|
||||
books: 'books',
|
||||
book_notes: 'notes',
|
||||
book_configs: 'configs',
|
||||
};
|
||||
|
||||
type TableName = keyof typeof transformsToDB;
|
||||
|
||||
type DBError = { table: TableName; error: PostgrestError };
|
||||
|
||||
const getUserAndToken = async (req: NextRequest) => {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const sinceParam = searchParams.get('since');
|
||||
const typeParam = searchParams.get('type') as SyncType | undefined;
|
||||
const bookParam = searchParams.get('book');
|
||||
|
||||
if (!sinceParam) {
|
||||
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const since = new Date(Number(sinceParam));
|
||||
if (isNaN(since.getTime())) {
|
||||
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sinceIso = since.toISOString();
|
||||
|
||||
try {
|
||||
const results: SyncResult = { books: [], configs: [], notes: [] };
|
||||
const errors: Record<TableName, DBError | null> = {
|
||||
books: null,
|
||||
book_notes: null,
|
||||
book_configs: null,
|
||||
};
|
||||
|
||||
const queryTables = async (table: TableName) => {
|
||||
let query = supabase.from(table).select('*').eq('user_id', user.id);
|
||||
if (bookParam) {
|
||||
query.eq('book_hash', bookParam);
|
||||
}
|
||||
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
|
||||
console.log('Querying table:', table, 'since:', sinceIso);
|
||||
const { data, error } = await query;
|
||||
if (error) throw { table, error } as DBError;
|
||||
results[DBSyncTypeMap[table] as SyncType] = data || [];
|
||||
};
|
||||
|
||||
if (!typeParam || typeParam === 'books') {
|
||||
await queryTables('books').catch((err) => (errors['books'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'configs') {
|
||||
await queryTables('book_configs').catch((err) => (errors['book_configs'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'notes') {
|
||||
await queryTables('book_notes').catch((err) => (errors['book_notes'] = err));
|
||||
}
|
||||
|
||||
const dbErrors = Object.values(errors).filter((err) => err !== null);
|
||||
if (dbErrors.length > 0) {
|
||||
console.error('Errors occurred:', dbErrors);
|
||||
const errorMsg = dbErrors
|
||||
.map((err) => `${err.table}: ${err.error.message || 'Unknown error'}`)
|
||||
.join('; ');
|
||||
return NextResponse.json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(results, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
const body = await req.json();
|
||||
const { books = [], configs = [], notes = [] } = body as SyncData;
|
||||
|
||||
const upsertRecords = async (
|
||||
table: TableName,
|
||||
primaryKeys: (keyof BookDataRecord)[],
|
||||
records: BookDataRecord[],
|
||||
) => {
|
||||
const authoritativeRecords: BookDataRecord[] = [];
|
||||
|
||||
for (const rec of records) {
|
||||
const dbRec = transformsToDB[table](rec, user.id);
|
||||
rec.user_id = user.id;
|
||||
rec.book_hash = dbRec.book_hash;
|
||||
const matchConditions: Record<string, string | number> = { user_id: user.id };
|
||||
for (const pk of primaryKeys) {
|
||||
matchConditions[pk] = rec[pk]!;
|
||||
}
|
||||
|
||||
const { data: serverData, error: fetchError } = await supabase
|
||||
.from(table)
|
||||
.select()
|
||||
.match(matchConditions)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
return { error: fetchError.message };
|
||||
}
|
||||
|
||||
if (!serverData) {
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from(table)
|
||||
.insert(dbRec)
|
||||
.select()
|
||||
.single();
|
||||
if (insertError) return { error: insertError.message };
|
||||
authoritativeRecords.push(inserted);
|
||||
} else {
|
||||
const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0;
|
||||
const serverUpdatedAt = serverData.updated_at
|
||||
? new Date(serverData.updated_at).getTime()
|
||||
: 0;
|
||||
const clientDeletedAt = dbRec.deleted_at ? new Date(dbRec.deleted_at).getTime() : 0;
|
||||
const serverDeletedAt = serverData.deleted_at
|
||||
? new Date(serverData.deleted_at).getTime()
|
||||
: 0;
|
||||
const clientIsNewer =
|
||||
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
|
||||
|
||||
if (clientIsNewer) {
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from(table)
|
||||
.update(dbRec)
|
||||
.match(matchConditions)
|
||||
.select()
|
||||
.single();
|
||||
console.log('Updating record:', updated);
|
||||
if (updateError) return { error: updateError.message };
|
||||
authoritativeRecords.push(updated);
|
||||
} else {
|
||||
authoritativeRecords.push(serverData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data: authoritativeRecords };
|
||||
};
|
||||
|
||||
try {
|
||||
const [booksResult, configsResult, notesResult] = await Promise.all([
|
||||
upsertRecords('books', ['book_hash'], books as BookDataRecord[]),
|
||||
upsertRecords('book_configs', ['book_hash'], configs as BookDataRecord[]),
|
||||
upsertRecords('book_notes', ['book_hash', 'id'], notes as BookDataRecord[]),
|
||||
]);
|
||||
|
||||
if (booksResult?.error) throw new Error(booksResult.error);
|
||||
if (configsResult?.error) throw new Error(configsResult.error);
|
||||
if (notesResult?.error) throw new Error(notesResult.error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
books: booksResult?.data || [],
|
||||
configs: configsResult?.data || [],
|
||||
notes: notesResult?.data || [],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -26,19 +26,19 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
||||
if (booksGroup) {
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.lastUpdated = Math.max(acc[groupIndex]!.lastUpdated, book.lastUpdated);
|
||||
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
acc.push({
|
||||
name: book.group,
|
||||
books: [book],
|
||||
lastUpdated: book.lastUpdated,
|
||||
updatedAt: book.updatedAt,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
|
||||
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
};
|
||||
|
||||
interface BookshelfProps {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface BookMenuProps {
|
||||
@@ -16,7 +18,9 @@ interface BookMenuProps {
|
||||
|
||||
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
@@ -34,6 +38,9 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
|
||||
const handleUserLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -26,6 +27,7 @@ import { AboutWindow } from '@/components/AboutWindow';
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
library: libraryBooks,
|
||||
setLibrary,
|
||||
@@ -33,7 +35,7 @@ const LibraryPage = () => {
|
||||
clearOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
useTheme();
|
||||
const { setSettings } = useSettingsStore();
|
||||
const { setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
@@ -74,6 +76,20 @@ const LibraryPage = () => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const initLogin = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
if (token && user) {
|
||||
if (!settings.keepLogin) {
|
||||
settings.keepLogin = true;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
}
|
||||
} else if (settings.keepLogin) {
|
||||
router.push('/auth');
|
||||
}
|
||||
};
|
||||
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
@@ -104,6 +120,7 @@ const LibraryPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
initLogin();
|
||||
initLibrary();
|
||||
return () => {
|
||||
clearOpenWithBooks();
|
||||
|
||||
@@ -38,7 +38,8 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
cfi,
|
||||
text: truncatedText,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
bookmarks.push(bookmark);
|
||||
const updatedConfig = updateBooknotes(bookKey, bookmarks);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useClickEvent } from '../hooks/useClickEvent';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
@@ -15,57 +18,7 @@ import {
|
||||
handleClick,
|
||||
handleWheel,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export interface FoliateView extends HTMLElement {
|
||||
open: (book: BookDoc) => Promise<void>;
|
||||
close: () => void;
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
prev: (distance: number) => void;
|
||||
next: (distance: number) => void;
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
select: (target: string | number | { fraction: number }) => void;
|
||||
deselect: () => void;
|
||||
history: {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
back: () => void;
|
||||
forward: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
renderer: {
|
||||
scrolled?: boolean;
|
||||
viewSize: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||
// transform BookNote to foliate annotation
|
||||
const annotation = {
|
||||
value: note.cfi,
|
||||
...note,
|
||||
};
|
||||
return originalAddAnnotation(annotation, remove);
|
||||
};
|
||||
return originalView;
|
||||
};
|
||||
import Toast from '@/components/Toast';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -75,12 +28,18 @@ const FoliateViewer: React.FC<{
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey, setViewSettings } = useReaderStore();
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setToastMessage(''), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toastMessage]);
|
||||
|
||||
useProgressSync(bookKey, config, setToastMessage);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -88,34 +47,7 @@ const FoliateViewer: React.FC<{
|
||||
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
||||
};
|
||||
|
||||
const handleScrollbarAutoHide = (doc: Document) => {
|
||||
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
||||
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
||||
const container = iframe.parentElement?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
||||
const showScrollbar = () => {
|
||||
container.style.overflow = 'auto';
|
||||
container.style.scrollbarWidth = 'thin';
|
||||
};
|
||||
|
||||
const hideScrollbar = () => {
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.scrollbarWidth = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
container.style.overflow = 'auto';
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', () => {
|
||||
showScrollbar();
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
||||
});
|
||||
hideScrollbar();
|
||||
}
|
||||
};
|
||||
|
||||
const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
|
||||
const docLoadHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc loaded:', detail);
|
||||
@@ -157,72 +89,7 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
if (viewSettings.disableClick!) {
|
||||
return;
|
||||
}
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX, screenY } = msg.data;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||
screenX,
|
||||
screenY,
|
||||
});
|
||||
if (!consumed) {
|
||||
const centerStartX = rect.left + rect.width * 0.375;
|
||||
const centerEndX = rect.left + rect.width * 0.625;
|
||||
const centerStartY = rect.top + rect.height * 0.375;
|
||||
const centerEndY = rect.top + rect.height * 0.625;
|
||||
if (
|
||||
screenX >= centerStartX &&
|
||||
screenX <= centerEndX &&
|
||||
screenY >= centerStartY &&
|
||||
screenY <= centerEndY
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
onRelocate: progressRelocateHandler,
|
||||
@@ -291,11 +158,16 @@ const FoliateViewer: React.FC<{
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
{toastMessage && (
|
||||
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FoliateView } from './FoliateViewer';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
|
||||
@@ -225,7 +225,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
cfi,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const existingIndex = annotations.findIndex(
|
||||
@@ -260,7 +261,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
color,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const existingIndex = annotations.findIndex(
|
||||
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
||||
@@ -389,7 +391,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{toastMessage && <Toast message={toastMessage} />}
|
||||
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -73,7 +73,8 @@ const Notebook: React.FC = ({}) => {
|
||||
cfi,
|
||||
note,
|
||||
text: selection.text,
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
annotations.push(annotation);
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
@@ -89,7 +90,7 @@ const Notebook: React.FC = ({}) => {
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
|
||||
if (existingIndex === -1) return;
|
||||
annotation.modified = Date.now();
|
||||
annotation.updatedAt = Date.now();
|
||||
annotations[existingIndex] = annotation;
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
if (updatedConfig) {
|
||||
@@ -117,10 +118,10 @@ const Notebook: React.FC = ({}) => {
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
const annotationNotes = allNotes
|
||||
.filter((note) => note.type === 'annotation' && note.note)
|
||||
.sort((a, b) => b.created - a.created);
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
const excerptNotes = allNotes
|
||||
.filter((note) => note.type === 'excerpt')
|
||||
.sort((a, b) => a.created - b.created);
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
return isNotebookVisible ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
||||
export const useAutoHideScrollbar = () => {
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
const handleScrollbarAutoHide = (doc: Document) => {
|
||||
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
||||
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
||||
const container = iframe.parentElement?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
||||
const showScrollbar = () => {
|
||||
container.style.overflow = 'auto';
|
||||
container.style.scrollbarWidth = 'thin';
|
||||
};
|
||||
|
||||
const hideScrollbar = () => {
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.scrollbarWidth = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
container.style.overflow = 'auto';
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', () => {
|
||||
showScrollbar();
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
||||
});
|
||||
hideScrollbar();
|
||||
}
|
||||
};
|
||||
|
||||
return { shouldAutoHideScrollbar, handleScrollbarAutoHide };
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export const useClickEvent = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
if (viewSettings.disableClick!) {
|
||||
return;
|
||||
}
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX, screenY } = msg.data;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||
screenX,
|
||||
screenY,
|
||||
});
|
||||
if (!consumed) {
|
||||
const centerStartX = rect.left + rect.width * 0.375;
|
||||
const centerEndX = rect.left + rect.width * 0.625;
|
||||
const centerStartY = rect.top + rect.height * 0.375;
|
||||
const centerEndY = rect.top + rect.height * 0.625;
|
||||
if (
|
||||
screenX >= centerStartX &&
|
||||
screenX <= centerEndX &&
|
||||
screenY >= centerStartY &&
|
||||
screenY <= centerEndY
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
return {
|
||||
handleTurnPage,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
import { FoliateView } from '@/types/view';
|
||||
|
||||
type FoliateEventHandler = {
|
||||
onLoad?: (event: Event) => void;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
export const useProgressSync = (
|
||||
bookKey: string,
|
||||
config: BookConfig,
|
||||
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
|
||||
) => {
|
||||
const { setConfig } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { syncedConfigs, syncConfigs } = useSync();
|
||||
const { user } = useAuth();
|
||||
const view = getView(bookKey);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig) => {
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
const newConfig = { bookHash, ...config };
|
||||
const compressedConfig = JSON.parse(
|
||||
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
||||
);
|
||||
syncConfigs([compressedConfig], bookHash, 'push');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
return () => {
|
||||
pushConfig(bookKey, config);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastProgressSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastProgressSyncTime.current;
|
||||
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
|
||||
lastProgressSyncTime.current = now;
|
||||
pushConfig(bookKey, config);
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastProgressSyncTime.current = Date.now();
|
||||
pushConfig(bookKey, config);
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
// sync progress once when the book is opened
|
||||
const configSynced = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!configSynced.current && syncedConfigs?.length > 0) {
|
||||
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||
if (syncedConfig) {
|
||||
const newConfig = deserializeConfig(
|
||||
JSON.stringify(syncedConfig),
|
||||
settings.globalViewSettings,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
);
|
||||
setConfig(bookKey, { ...config, ...newConfig });
|
||||
configSynced.current = true;
|
||||
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config.progress?.[1] ?? 0) > 0) {
|
||||
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
|
||||
const configFraction = config.progress![0] / config.progress![1];
|
||||
if (syncedFraction > configFraction) {
|
||||
view?.goToFraction(syncedFraction);
|
||||
setToastMessage?.('Progress synced');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedConfigs]);
|
||||
};
|
||||
Reference in New Issue
Block a user