Add manga reading support with RTL and vertical scroll modes
- settings.ts: Manga reading settings and configuration - reading-direction.ts: Right-to-left reading direction support - vertical-scroll-mode.ts: Webtoon/vertical scroll reading mode - rtl-navigator.ts: RTL navigation for manga
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// Detect reading direction from metadata or user preference
|
||||
|
||||
type ReadingDirection = "auto" | "ltr" | "rtl" | "vertical";
|
||||
|
||||
interface ReadingDirectionState {
|
||||
direction: ReadingDirection;
|
||||
detectedDirection: "ltr" | "rtl" | "vertical";
|
||||
userPreference: ReadingDirection | null;
|
||||
}
|
||||
|
||||
async function detectReadingDirection(
|
||||
metadata: MediaItemMetadata,
|
||||
): Promise<ReadingDirectionState> {
|
||||
// Check user preference first
|
||||
const userPreference = await getUserReadingDirectionPreference();
|
||||
if (userPreference && userPreference !== "auto") {
|
||||
return {
|
||||
direction: userPreference,
|
||||
detectedDirection: "ltr", // Default fallback
|
||||
userPreference,
|
||||
};
|
||||
}
|
||||
|
||||
// Detect from metadata
|
||||
const detectedDirection = detectFromMetadata(metadata);
|
||||
|
||||
return {
|
||||
direction: "auto",
|
||||
detectedDirection,
|
||||
userPreference: null,
|
||||
};
|
||||
}
|
||||
|
||||
function detectFromMetadata(
|
||||
metadata: MediaItemMetadata,
|
||||
): "ltr" | "rtl" | "vertical" {
|
||||
// Check manga_type field from database
|
||||
const mangaType = (metadata as any).manga_type;
|
||||
if (mangaType === "yes_and_right_to_left" || mangaType === "yes") {
|
||||
return "rtl";
|
||||
}
|
||||
|
||||
// Check reading_direction field
|
||||
const readingDirection = (metadata as any).reading_direction;
|
||||
if (readingDirection === "rtl" || readingDirection === "vertical") {
|
||||
return readingDirection;
|
||||
}
|
||||
|
||||
// Detect from filename
|
||||
const filename = metadata.filePath.toLowerCase();
|
||||
if (
|
||||
filename.includes("manga") ||
|
||||
filename.includes("manhwa") ||
|
||||
filename.includes("webtoon")
|
||||
) {
|
||||
return "vertical";
|
||||
}
|
||||
|
||||
// Default to LTR
|
||||
return "ltr";
|
||||
}
|
||||
|
||||
async function getUserReadingDirectionPreference(): Promise<ReadingDirection | null> {
|
||||
const userId = localStorage.getItem("userId");
|
||||
if (!userId) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/settings`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const settings = await response.json();
|
||||
return settings.reading_direction || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveDirection(
|
||||
state: ReadingDirectionState,
|
||||
): "ltr" | "rtl" | "vertical" {
|
||||
if (state.direction !== "auto") {
|
||||
return state.direction as "ltr" | "rtl" | "vertical";
|
||||
}
|
||||
return state.detectedDirection;
|
||||
}
|
||||
|
||||
function shouldUseRTL(state: ReadingDirectionState): boolean {
|
||||
return getEffectiveDirection(state) === "rtl";
|
||||
}
|
||||
|
||||
function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
|
||||
return getEffectiveDirection(state) === "vertical";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Right-to-left navigation for manga
|
||||
// Reverses page turn direction and key bindings
|
||||
|
||||
interface RTLNavigatorState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
readingDirection: "rtl" | "ltr";
|
||||
}
|
||||
|
||||
function createRTLNavigator(totalPages: number): RTLNavigatorState {
|
||||
return {
|
||||
currentPage: 1,
|
||||
totalPages,
|
||||
readingDirection: "rtl",
|
||||
};
|
||||
}
|
||||
|
||||
function getNextPage(state: RTLNavigatorState): number {
|
||||
// In RTL, "next" page means moving left (decreasing page number)
|
||||
if (state.readingDirection === "rtl") {
|
||||
return Math.max(1, state.currentPage - 1);
|
||||
}
|
||||
return Math.min(state.totalPages, state.currentPage + 1);
|
||||
}
|
||||
|
||||
function getPreviousPage(state: RTLNavigatorState): number {
|
||||
// In RTL, "previous" page means moving right (increasing page number)
|
||||
if (state.readingDirection === "rtl") {
|
||||
return Math.min(state.totalPages, state.currentPage + 1);
|
||||
}
|
||||
return Math.max(1, state.currentPage - 1);
|
||||
}
|
||||
|
||||
function navigateToPage(
|
||||
state: RTLNavigatorState,
|
||||
pageNumber: number,
|
||||
): RTLNavigatorState {
|
||||
return {
|
||||
...state,
|
||||
currentPage: Math.max(1, Math.min(state.totalPages, pageNumber)),
|
||||
};
|
||||
}
|
||||
|
||||
function getProgress(state: RTLNavigatorState): {
|
||||
current: number;
|
||||
total: number;
|
||||
} {
|
||||
return {
|
||||
current: state.currentPage,
|
||||
total: state.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
function getReadingProgressPercentage(state: RTLNavigatorState): number {
|
||||
return (state.currentPage / state.totalPages) * 100;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Manga-specific settings integration
|
||||
// Extends the common reader settings manager
|
||||
|
||||
interface MangaSettings {
|
||||
readingDirection: "auto" | "ltr" | "rtl" | "vertical";
|
||||
verticalScrollSpeed: "slow" | "normal" | "fast";
|
||||
rtlPageTransition: "slide" | "fade" | "none";
|
||||
webtoonMode: boolean;
|
||||
}
|
||||
|
||||
async function getMangaSettings(): Promise<MangaSettings> {
|
||||
const defaultSettings: MangaSettings = {
|
||||
readingDirection: "auto",
|
||||
verticalScrollSpeed: "normal",
|
||||
rtlPageTransition: "slide",
|
||||
webtoonMode: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const userId = localStorage.getItem("userId");
|
||||
const response = await fetch(`/api/users/${userId}/settings`);
|
||||
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
return { ...defaultSettings, ...settings };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load manga settings:", error);
|
||||
}
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
async function updateMangaSettings(
|
||||
settings: Partial<MangaSettings>,
|
||||
): Promise<void> {
|
||||
const userId = localStorage.getItem("userId");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/settings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update manga settings");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save manga settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMangaSettings(settings: MangaSettings): void {
|
||||
// Apply reading direction
|
||||
document.documentElement.dataset.readingDirection = settings.readingDirection;
|
||||
|
||||
// Apply vertical scroll speed
|
||||
if (settings.verticalScrollSpeed === "slow") {
|
||||
document.documentElement.style.scrollBehavior = "smooth";
|
||||
} else if (settings.verticalScrollSpeed === "fast") {
|
||||
document.documentElement.style.scrollBehavior = "auto";
|
||||
}
|
||||
|
||||
// Apply RTL page transition
|
||||
if (settings.rtlPageTransition !== "none") {
|
||||
document.documentElement.dataset.pageTransition =
|
||||
settings.rtlPageTransition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Vertical scroll mode for webtoons/manhwa
|
||||
// Infinite scroll with image loading and lazy rendering
|
||||
|
||||
interface VerticalScrollState {
|
||||
container: HTMLElement;
|
||||
loadedPages: Set<number>;
|
||||
loadingPages: Set<number>;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
threshold: number; // Distance from bottom to trigger next page load
|
||||
mediaItemId: string;
|
||||
}
|
||||
|
||||
function createVerticalScroll(
|
||||
container: HTMLElement,
|
||||
mediaItemId: string,
|
||||
totalPages: number,
|
||||
): VerticalScrollState {
|
||||
const state: VerticalScrollState = {
|
||||
container,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
currentPage: 1,
|
||||
totalPages,
|
||||
threshold: 500, // Load next page when 500px from bottom
|
||||
mediaItemId,
|
||||
};
|
||||
|
||||
// Initial page load
|
||||
loadPage(state, 1);
|
||||
|
||||
// Setup scroll listener
|
||||
setupScrollListener(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function loadPage(
|
||||
state: VerticalScrollState,
|
||||
pageNumber: number,
|
||||
): Promise<void> {
|
||||
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingPages.add(pageNumber);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
|
||||
const pageContainer = document.createElement("div");
|
||||
pageContainer.className = "vertical-page";
|
||||
pageContainer.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.alt = `Page ${pageNumber}`;
|
||||
img.loading = "lazy";
|
||||
|
||||
pageContainer.appendChild(img);
|
||||
state.container.appendChild(pageContainer);
|
||||
|
||||
state.loadedPages.add(pageNumber);
|
||||
state.loadingPages.delete(pageNumber);
|
||||
|
||||
// Load next pages proactively
|
||||
if (pageNumber < state.totalPages) {
|
||||
loadPage(state, pageNumber + 1);
|
||||
if (pageNumber + 1 < state.totalPages) {
|
||||
loadPage(state, pageNumber + 2);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load page ${pageNumber}:`, error);
|
||||
state.loadingPages.delete(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
function setupScrollListener(state: VerticalScrollState): void {
|
||||
let scrollTimeout: number | undefined;
|
||||
|
||||
state.container.addEventListener("scroll", () => {
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
checkScrollPosition(state);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function checkScrollPosition(state: VerticalScrollState): void {
|
||||
const scrollBottom =
|
||||
state.container.scrollHeight -
|
||||
state.container.scrollTop -
|
||||
state.container.clientHeight;
|
||||
|
||||
if (scrollBottom < state.threshold) {
|
||||
const lastPage = Math.max(...state.loadedPages);
|
||||
if (lastPage < state.totalPages) {
|
||||
loadPage(state, lastPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Update current page based on scroll position
|
||||
const currentPage = getCurrentPageFromScroll(state);
|
||||
if (currentPage !== state.currentPage) {
|
||||
state.currentPage = currentPage;
|
||||
// Dispatch event for progress tracking
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("page-change", {
|
||||
detail: { page: currentPage },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPageFromScroll(state: VerticalScrollState): number {
|
||||
const pages = state.container.querySelectorAll(".vertical-page");
|
||||
|
||||
for (const page of pages) {
|
||||
const rect = page.getBoundingClientRect();
|
||||
const containerRect = state.container.getBoundingClientRect();
|
||||
|
||||
// Page is considered "current" if it's in the middle 50% of viewport
|
||||
const pageMiddle = rect.top + rect.height / 2;
|
||||
const viewportMiddle = containerRect.top + containerRect.height / 2;
|
||||
|
||||
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
|
||||
return parseInt(page.dataset.pageNumber || "1");
|
||||
}
|
||||
}
|
||||
|
||||
return state.currentPage;
|
||||
}
|
||||
|
||||
function destroyVerticalScroll(state: VerticalScrollState): void {
|
||||
// Clean up blob URLs
|
||||
const images = state.container.querySelectorAll("img");
|
||||
images.forEach((img) => {
|
||||
const url = img.src;
|
||||
if (url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
state.container.innerHTML = "";
|
||||
state.loadedPages.clear();
|
||||
state.loadingPages.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user