Support reading progress synchronization across platforms (#34)

This commit is contained in:
Huang Xin
2024-12-24 12:31:02 +01:00
committed by GitHub
parent d7ccbbcaa2
commit 9be9bc8a34
30 changed files with 1135 additions and 214 deletions
@@ -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]);
};