feat(ui): image viewing mode support (#3328)

* feat(ui): image viewing mode support

* Revert foliate-js submodule pointer to 72fda6a (CI-compatible)

* Fix long-press image viewing in foliateViewer instead of foliate-js and other refactors

* feat: add images navigation buttons and table viewer

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
JustADeer
2026-02-22 23:28:48 +07:00
committed by GitHub
parent eec2c39f19
commit b99c1bc19a
35 changed files with 1301 additions and 32 deletions
@@ -1079,5 +1079,11 @@
"Books": "الكتب",
"Groups": "المجموعات",
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
"Metadata": "بيانات وصفية"
"Metadata": "بيانات وصفية",
"Image viewer": "عارض الصور",
"Previous Image": "الصورة السابقة",
"Next Image": "الصورة التالية",
"Zoomed": "مكبّر",
"Zoom level": "مستوى التكبير",
"Table viewer": "عارض الجداول"
}
@@ -1031,5 +1031,11 @@
"Books": "বই",
"Groups": "দল",
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
"Metadata": "মেটাডেটা"
"Metadata": "মেটাডেটা",
"Image viewer": "চিত্র প্রদর্শক",
"Previous Image": "পূর্ববর্তী চিত্র",
"Next Image": "পরবর্তী চিত্র",
"Zoomed": "জুম করা হয়েছে",
"Zoom level": "জুম স্তর",
"Table viewer": "টেবিল প্রদর্শক"
}
@@ -1019,5 +1019,11 @@
"Books": "དཔེ་ཆ།",
"Groups": "ཚོགས་པ།",
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
"Metadata": "གནད་སྨིན་གོ་དོན།"
"Metadata": "གནད་སྨིན་གོ་དོན།",
"Image viewer": "པར་རིས་ལྟ་བྱེད།",
"Previous Image": "སྔོན་མའི་པར་རིས།",
"Next Image": "རྗེས་མའི་པར་རིས།",
"Zoomed": "ཆེར་བསྐྱེད་ཟིན།",
"Zoom level": "ཆེར་བསྐྱེད་རིམ་པ།",
"Table viewer": "རེའུ་མིག་ལྟ་བྱེད།"
}
@@ -1031,5 +1031,11 @@
"Books": "Bücher",
"Groups": "Gruppen",
"Back to TTS Location": "Zurück zur TTS-Position",
"Metadata": "Metadaten"
"Metadata": "Metadaten",
"Image viewer": "Bildbetrachter",
"Previous Image": "Vorheriges Bild",
"Next Image": "Nächstes Bild",
"Zoomed": "Gezoomt",
"Zoom level": "Zoomstufe",
"Table viewer": "Tabellenbetrachter"
}
@@ -1031,5 +1031,11 @@
"Books": "Βιβλία",
"Groups": "Ομάδες",
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
"Metadata": "Μεταδεδομένα"
"Metadata": "Μεταδεδομένα",
"Image viewer": "Πρόγραμμα προβολής εικόνων",
"Previous Image": "Προηγούμενη εικόνα",
"Next Image": "Επόμενη εικόνα",
"Zoomed": "Ζουμαρισμένο",
"Zoom level": "Επίπεδο ζουμ",
"Table viewer": "Πρόγραμμα προβολής πινάκων"
}
@@ -1043,5 +1043,11 @@
"Books": "Libros",
"Groups": "Grupos",
"Back to TTS Location": "Volver a la ubicación de TTS",
"Metadata": "Metadatos"
"Metadata": "Metadatos",
"Image viewer": "Visor de imágenes",
"Previous Image": "Imagen anterior",
"Next Image": "Imagen siguiente",
"Zoomed": "Con zoom",
"Zoom level": "Nivel de zoom",
"Table viewer": "Visor de tablas"
}
@@ -1031,5 +1031,11 @@
"Books": "کتاب‌ها",
"Groups": "گروه‌ها",
"Back to TTS Location": "بازگشت به مکان TTS",
"Metadata": "فراداده"
"Metadata": "فراداده",
"Image viewer": "نمایشگر تصویر",
"Previous Image": "تصویر قبلی",
"Next Image": "تصویر بعدی",
"Zoomed": "بزرگنمایی شده",
"Zoom level": "سطح بزرگنمایی",
"Table viewer": "نمایشگر جدول"
}
@@ -1043,5 +1043,11 @@
"Books": "Livres",
"Groups": "Groupes",
"Back to TTS Location": "Retour à l'emplacement TTS",
"Metadata": "Métadonnées"
"Metadata": "Métadonnées",
"Image viewer": "Visionneuse d'images",
"Previous Image": "Image précédente",
"Next Image": "Image suivante",
"Zoomed": "Zoomé",
"Zoom level": "Niveau de zoom",
"Table viewer": "Visionneuse de tableaux"
}
@@ -1043,5 +1043,11 @@
"Reveal in Finder": "הצג ב-Finder",
"Reveal in File Explorer": "הצג בסייר הקבצים",
"Reveal in Folder": "הצג בתיקייה",
"Metadata": "מטא-נתונים"
"Metadata": "מטא-נתונים",
"Image viewer": "מציג תמונות",
"Previous Image": "תמונה הקודמת",
"Next Image": "תמונה הבאה",
"Zoomed": "בזום",
"Zoom level": "רמת זום",
"Table viewer": "מציג טבלאות"
}
@@ -1031,5 +1031,11 @@
"Books": "पुस्तकें",
"Groups": "समूह",
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
"Metadata": "मेटाडाटा"
"Metadata": "मेटाडाटा",
"Image viewer": "छवि दर्शक",
"Previous Image": "पिछली छवि",
"Next Image": "अगली छवि",
"Zoomed": "ज़ूम किया गया",
"Zoom level": "ज़ूम स्तर",
"Table viewer": "तालिका दर्शक"
}
@@ -1019,5 +1019,11 @@
"Books": "Buku",
"Groups": "Grup",
"Back to TTS Location": "Kembali ke Lokasi TTS",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Penampil gambar",
"Previous Image": "Gambar Sebelumnya",
"Next Image": "Gambar Berikutnya",
"Zoomed": "Diperbesar",
"Zoom level": "Tingkat zoom",
"Table viewer": "Penampil tabel"
}
@@ -1043,5 +1043,11 @@
"Books": "Libri",
"Groups": "Gruppi",
"Back to TTS Location": "Torna alla posizione TTS",
"Metadata": "Metadati"
"Metadata": "Metadati",
"Image viewer": "Visualizzatore immagini",
"Previous Image": "Immagine precedente",
"Next Image": "Immagine successiva",
"Zoomed": "Zoomato",
"Zoom level": "Livello di zoom",
"Table viewer": "Visualizzatore tabelle"
}
@@ -1019,5 +1019,11 @@
"Books": "書籍",
"Groups": "グループ",
"Back to TTS Location": "TTSの位置に戻る",
"Metadata": "メタデータ"
"Metadata": "メタデータ",
"Image viewer": "画像ビューア",
"Previous Image": "前の画像",
"Next Image": "次の画像",
"Zoomed": "ズーム済み",
"Zoom level": "ズームレベル",
"Table viewer": "テーブルビューア"
}
@@ -1019,5 +1019,11 @@
"Books": "도서",
"Groups": "그룹",
"Back to TTS Location": "TTS 위치로 돌아가기",
"Metadata": "메타데이터"
"Metadata": "메타데이터",
"Image viewer": "이미지 뷰어",
"Previous Image": "이전 이미지",
"Next Image": "다음 이미지",
"Zoomed": "확대됨",
"Zoom level": "확대 수준",
"Table viewer": "테이블 뷰어"
}
@@ -1019,5 +1019,11 @@
"Books": "Buku",
"Groups": "Kumpulan",
"Back to TTS Location": "Kembali ke Lokasi TTS",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Penyorot imej",
"Previous Image": "Imej Sebelumnya",
"Next Image": "Imej Seterusnya",
"Zoomed": "Dizum",
"Zoom level": "Tahap zum",
"Table viewer": "Penyorot jadual"
}
@@ -1031,5 +1031,11 @@
"Books": "Boeken",
"Groups": "Groepen",
"Back to TTS Location": "Terug naar TTS-locatie",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Afbeeldingenviewer",
"Previous Image": "Vorige afbeelding",
"Next Image": "Volgende afbeelding",
"Zoomed": "Gezoomd",
"Zoom level": "Zoomniveau",
"Table viewer": "Tabelviewer"
}
@@ -1055,5 +1055,11 @@
"Books": "Książki",
"Groups": "Grupy",
"Back to TTS Location": "Powrót do lokalizacji TTS",
"Metadata": "Metadane"
"Metadata": "Metadane",
"Image viewer": "Przeglądarka obrazów",
"Previous Image": "Poprzedni obraz",
"Next Image": "Następny obraz",
"Zoomed": "Powiększone",
"Zoom level": "Poziom powiększenia",
"Table viewer": "Przeglądarka tabel"
}
@@ -1043,5 +1043,11 @@
"Books": "Livros",
"Groups": "Grupos",
"Back to TTS Location": "Voltar para a localização do TTS",
"Metadata": "Metadados"
"Metadata": "Metadados",
"Image viewer": "Visualizador de imagens",
"Previous Image": "Imagem anterior",
"Next Image": "Próxima imagem",
"Zoomed": "Com zoom",
"Zoom level": "Nível de zoom",
"Table viewer": "Visualizador de tabelas"
}
@@ -1055,5 +1055,11 @@
"Books": "Книги",
"Groups": "Группы",
"Back to TTS Location": "Вернуться к местоположению TTS",
"Metadata": "Метаданные"
"Metadata": "Метаданные",
"Image viewer": "Просмотрщик изображений",
"Previous Image": "Предыдущее изображение",
"Next Image": "Следующее изображение",
"Zoomed": "Увеличено",
"Zoom level": "Уровень масштаба",
"Table viewer": "Просмотрщик таблиц"
}
@@ -1031,5 +1031,11 @@
"Books": "පොත්",
"Groups": "කණ්ඩායම්",
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
"Metadata": "මෙටාඩේටා"
"Metadata": "මෙටාඩේටා",
"Image viewer": "රූප නරඹන්නා",
"Previous Image": "පූර්ව රූපය",
"Next Image": "මීළඟ රූපය",
"Zoomed": "විශාලනය කරන ලදී",
"Zoom level": "විශාලන මට්ටම",
"Table viewer": "වගු නරඹන්නා"
}
@@ -1031,5 +1031,11 @@
"Books": "Böcker",
"Groups": "Grupper",
"Back to TTS Location": "Tillbaka till TTS-plats",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Bildvisare",
"Previous Image": "Föregående bild",
"Next Image": "Nästa bild",
"Zoomed": "Zoomad",
"Zoom level": "Zoomnivå",
"Table viewer": "Tabellvisare"
}
@@ -1031,5 +1031,11 @@
"Books": "புத்தகங்கள்",
"Groups": "குழுக்கள்",
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
"Metadata": "மெட்டாடேட்டா"
"Metadata": "மெட்டாடேட்டா",
"Image viewer": "படக் காட்சிப்பவர்",
"Previous Image": "முந்தைய படம்",
"Next Image": "அடுத்த படம்",
"Zoomed": "பெரிதாக்கப்பட்டது",
"Zoom level": "பெரிதாக்கும் நிலை",
"Table viewer": "அட்டவணை காட்சிப்பவர்"
}
@@ -1019,5 +1019,11 @@
"Books": "หนังสือ",
"Groups": "กลุ่ม",
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
"Metadata": "เมทาดาตา"
"Metadata": "เมทาดาตา",
"Image viewer": "ตัวดูรูปภาพ",
"Previous Image": "รูปภาพก่อนหน้า",
"Next Image": "รูปภาพถัดไป",
"Zoomed": "ซูมแล้ว",
"Zoom level": "ระดับการซูม",
"Table viewer": "ตัวดูตาราง"
}
@@ -1031,5 +1031,11 @@
"Books": "Kitaplar",
"Groups": "Gruplar",
"Back to TTS Location": "TTS Konumuna Geri Dön",
"Metadata": "Meta veriler"
"Metadata": "Meta veriler",
"Image viewer": "Resim görüntüleyici",
"Previous Image": "Önceki Resim",
"Next Image": "Sonraki Resim",
"Zoomed": "Yakınlaştırıldı",
"Zoom level": "Yakınlaştırma düzeyi",
"Table viewer": "Tablo görüntüleyici"
}
@@ -1055,5 +1055,11 @@
"Books": "Книги",
"Groups": "Групи",
"Back to TTS Location": "Повернутися до розташування TTS",
"Metadata": "Метадані"
"Metadata": "Метадані",
"Image viewer": "Переглядач зображень",
"Previous Image": "Попереднє зображення",
"Next Image": "Наступне зображення",
"Zoomed": "Збільшено",
"Zoom level": "Рівень масштабу",
"Table viewer": "Переглядач таблиць"
}
@@ -1019,5 +1019,11 @@
"Books": "Sách",
"Groups": "Nhóm",
"Back to TTS Location": "Quay lại vị trí TTS",
"Metadata": "Siêu dữ liệu"
"Metadata": "Siêu dữ liệu",
"Image viewer": "Trình xem ảnh",
"Previous Image": "Ảnh trước",
"Next Image": "Ảnh sau",
"Zoomed": "Đã thu phóng",
"Zoom level": "Mức thu phóng",
"Table viewer": "Trình xem bảng"
}
@@ -1019,5 +1019,11 @@
"Books": "书籍",
"Groups": "分组",
"Back to TTS Location": "回到朗读位置",
"Metadata": "元数据"
"Metadata": "元数据",
"Image viewer": "图片查看器",
"Previous Image": "上一张图片",
"Next Image": "下一张图片",
"Zoomed": "已缩放",
"Zoom level": "缩放级别",
"Table viewer": "表格查看器"
}
@@ -1019,5 +1019,11 @@
"Books": "書籍",
"Groups": "分組",
"Back to TTS Location": "回到朗讀位置",
"Metadata": "元數據"
"Metadata": "元數據",
"Image viewer": "圖片檢視器",
"Previous Image": "上一張圖片",
"Next Image": "下一張圖片",
"Zoomed": "已縮放",
"Zoom level": "縮放級別",
"Table viewer": "表格檢視器"
}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { convertBlobUrlToDataUrl, BookDoc, getDirection } from '@/libs/document';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { Insets } from '@/types/misc';
@@ -10,7 +10,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
@@ -44,6 +44,7 @@ import {
handleTouchStart,
handleTouchMove,
handleTouchEnd,
addLongPressListeners,
} from '../utils/iframeEventHandlers';
import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
@@ -59,9 +60,11 @@ import { getViewInsets } from '@/utils/insets';
import { handleA11yNavigation } from '@/utils/a11y';
import { isCJKLang } from '@/utils/lang';
import { getLocale } from '@/utils/misc';
import { ParagraphControl } from './paragraph';
import Spinner from '@/components/Spinner';
import KOSyncConflictResolver from './KOSyncResolver';
import { ParagraphControl } from './paragraph';
import ImageViewer from './ImageViewer';
import TableViewer from './TableViewer';
declare global {
interface Window {
@@ -247,6 +250,7 @@ const FoliateViewer: React.FC<{
detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey));
detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey));
detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey));
addLongPressListeners(bookKey, detail.doc);
}
}
};
@@ -288,6 +292,88 @@ const FoliateViewer: React.FC<{
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll);
const touchHandlers = useTouchEvent(bookKey, handlePageFlip, handleContinuousScroll);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
const [imageList, setImageList] = useState<{ src: string; cfi: string | null }[]>([]);
const [currentImageIndex, setCurrentImageIndex] = useState<number>(0);
const handleImagePress = useCallback(async (src: string) => {
try {
// Get all images from the current document
const docs = viewRef.current?.renderer.getContents();
const allImages: { src: string; cfi: string | null }[] = [];
docs?.forEach(({ doc, index }) => {
const images = doc.querySelectorAll('img');
images.forEach((img) => {
if (img.src && index !== undefined && img.parentNode) {
const range = doc.createRange();
range.selectNodeContents(img);
const cfi = viewRef.current?.getCFI(index, range) || null;
allImages.push({ src: img.src, cfi });
}
});
});
// Find the index of the pressed image
const index = allImages.findIndex((img) => img.src === src);
setImageList(allImages);
setCurrentImageIndex(index >= 0 ? index : 0);
const dataUrl = await convertBlobUrlToDataUrl(src);
setSelectedImage(dataUrl);
} catch (error) {
console.error('Failed to load image:', error);
}
}, []);
const handleTablePress = useCallback((html: string) => {
setSelectedTableHtml(html);
}, []);
const handlePreviousImage = useCallback(async () => {
if (currentImageIndex > 0 && imageList.length > 0) {
const newIndex = currentImageIndex - 1;
setCurrentImageIndex(newIndex);
try {
const { src, cfi } = imageList[newIndex]!;
const dataUrl = await convertBlobUrlToDataUrl(src);
setSelectedImage(dataUrl);
if (cfi && viewRef.current) {
viewRef.current?.goTo(cfi);
}
} catch (error) {
console.error('Failed to load previous image:', error);
}
}
}, [currentImageIndex, imageList]);
const handleNextImage = useCallback(async () => {
if (currentImageIndex < imageList.length - 1 && imageList.length > 0) {
const newIndex = currentImageIndex + 1;
setCurrentImageIndex(newIndex);
try {
const { src, cfi } = imageList[newIndex]!;
const dataUrl = await convertBlobUrlToDataUrl(src);
setSelectedImage(dataUrl);
if (cfi && viewRef.current) {
viewRef.current?.goTo(cfi);
}
} catch (error) {
console.error('Failed to load next image:', error);
}
}
}, [currentImageIndex, imageList]);
const handleCloseImage = useCallback(() => {
setSelectedImage(null);
setImageList([]);
setCurrentImageIndex(0);
}, []);
useLongPressEvent(bookKey, handleImagePress, handleTablePress);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
onRelocate: progressRelocateHandler,
@@ -501,6 +587,21 @@ const FoliateViewer: React.FC<{
return (
<>
{selectedImage && (
<ImageViewer
src={selectedImage}
onClose={handleCloseImage}
onPrevious={currentImageIndex > 0 ? handlePreviousImage : undefined}
onNext={currentImageIndex < imageList.length - 1 ? handleNextImage : undefined}
/>
)}
{selectedTableHtml && (
<TableViewer
html={selectedTableHtml}
isDarkMode={isDarkMode}
onClose={() => setSelectedTableHtml(null)}
/>
)}
<div
ref={containerRef}
tabIndex={-1}
@@ -0,0 +1,472 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import ZoomControls from './ZoomControls';
interface ImageViewerProps {
src: string | null;
onClose: () => void;
onPrevious?: () => void;
onNext?: () => void;
}
const MIN_SCALE = 0.5;
const MAX_SCALE = 8;
const ZOOM_SPEED = 0.1;
const MOBILE_ZOOM_SPEED = 0.001;
const ZOOM_BIAS = 1.05;
const ImageViewer: React.FC<ImageViewerProps> = ({ src, onClose, onPrevious, onNext }) => {
const _ = useTranslation();
const [scale, setScale] = useState(1);
const [zoomSpeed, setZoomSpeed] = useState(0.1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [showZoomLabel, setShowZoomLabel] = useState(true);
const lastTouchDistance = useRef<number>(0);
const dragStart = useRef({ x: 0, y: 0 });
const wasDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideZoomLabelAfterDelay = () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
}
setShowZoomLabel(true);
zoomLabelTimeoutRef.current = setTimeout(() => {
setShowZoomLabel(false);
}, 2000);
};
const handleZoomIn = () => {
const newScale = Math.min(scale + ZOOM_SPEED, MAX_SCALE);
setScale(newScale);
setZoomSpeed(ZOOM_SPEED * ZOOM_BIAS * newScale);
hideZoomLabelAfterDelay();
};
const handleZoomOut = () => {
const newScale = Math.max(scale - ZOOM_SPEED, MIN_SCALE);
if (newScale <= 1) {
setPosition({ x: 0, y: 0 });
setScale(newScale);
setZoomSpeed(ZOOM_SPEED);
} else {
setScale(newScale);
setZoomSpeed(ZOOM_SPEED * ZOOM_BIAS * newScale);
}
hideZoomLabelAfterDelay();
};
const handleResetZoom = () => {
setScale(1);
setPosition({ x: 0, y: 0 });
setZoomSpeed(ZOOM_SPEED);
hideZoomLabelAfterDelay();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
onClose();
return;
}
// Arrow key navigation
if (e.key === 'ArrowLeft' && onPrevious) {
e.preventDefault();
handlePreviousImage();
return;
}
if (e.key === 'ArrowRight' && onNext) {
e.preventDefault();
handleNextImage();
return;
}
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
if (isCtrlOrCmd) {
e.preventDefault();
if (e.key === '=' || e.key === '+') {
handleZoomIn();
} else if (e.key === '-' || e.key === '_') {
handleZoomOut();
} else if (e.key === '0') {
handleResetZoom();
}
}
};
const handlePreviousImage = () => {
if (onPrevious) {
onPrevious();
hideZoomLabelAfterDelay();
}
};
const handleNextImage = () => {
if (onNext) {
onNext();
hideZoomLabelAfterDelay();
}
};
const getZoomedOffset = (
anchorX: number,
anchorY: number,
currentScale: number,
nextScale: number,
currentPos: { x: number; y: number },
) => {
const scaleChange = nextScale / currentScale;
return {
x: anchorX - (anchorX - currentPos.x) * scaleChange,
y: anchorY - (anchorY - currentPos.y) * scaleChange,
};
};
// Grab Focus of modal and set up initial zoom label timeout
useEffect(() => {
containerRef.current?.focus();
setTimeout(() => {
hideZoomLabelAfterDelay();
}, 0);
return () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
}
};
}, []);
const handleWheel = (e: React.WheelEvent) => {
// Only zoom when Ctrl/Cmd is pressed for consistency with browser behavior
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
if (!isCtrlOrCmd) {
// Allow default behavior (no zoom without modifier)
return;
}
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed;
const newScale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
const newZoom = ZOOM_SPEED * ZOOM_BIAS * newScale;
if (newScale <= 1) {
setPosition({ x: 0, y: 0 });
setScale(newScale);
setZoomSpeed(ZOOM_SPEED);
hideZoomLabelAfterDelay();
return;
}
// Mouse position relative to the container element
const mouseX = e.clientX - rect.left - rect.width / 2;
const mouseY = e.clientY - rect.top - rect.height / 2;
setPosition((prevPos) => {
return getZoomedOffset(mouseX, mouseY, scale, newScale, prevPos);
});
setScale(newScale);
setZoomSpeed(newZoom);
hideZoomLabelAfterDelay();
};
const handleImageMouseDown = (e: React.MouseEvent) => {
if (isDragging || scale <= 1) return;
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
wasDragging.current = false;
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
};
const handleImageMouseMove = (e: React.MouseEvent) => {
if (!isDragging || scale <= 1) return;
e.preventDefault();
wasDragging.current = true;
const newX = e.clientX - dragStart.current.x;
const newY = e.clientY - dragStart.current.y;
setPosition({ x: newX, y: newY });
};
const handleImageMouseUp = (e: React.MouseEvent) => {
if (isDragging) {
e.stopPropagation();
}
setIsDragging(false);
};
const onTouchStart = (e: React.TouchEvent) => {
const touches = e.touches;
if (touches.length === 1 && scale > 1) {
// Pan Start
setIsDragging(true);
wasDragging.current = false;
const touch = touches[0];
if (!touch) return;
dragStart.current = {
x: touch.clientX - position.x,
y: touch.clientY - position.y,
};
} else if (touches.length === 2) {
// Pinch Start
setIsDragging(true);
wasDragging.current = false;
const touch1 = touches[0];
const touch2 = touches[1];
if (!touch1 || !touch2) return;
lastTouchDistance.current = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY,
);
}
};
const onTouchMove = (e: React.TouchEvent) => {
const touches = e.touches;
if (touches.length === 1 && scale > 1 && isDragging) {
// Pan
wasDragging.current = true;
const touch = touches[0];
if (!touch) return;
requestAnimationFrame(() => {
const newX = touch.clientX - dragStart.current.x;
const newY = touch.clientY - dragStart.current.y;
setPosition({ x: newX, y: newY });
});
} else if (touches.length === 2) {
// Pinch
wasDragging.current = true;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const touch1 = touches[0];
const touch2 = touches[1];
if (!touch1 || !touch2) return;
const currentDistance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY,
);
const distanceChange = currentDistance / lastTouchDistance.current;
requestAnimationFrame(() => {
const newScale = Math.min(Math.max(scale * distanceChange, MIN_SCALE), MAX_SCALE);
const newZoom = MOBILE_ZOOM_SPEED * ZOOM_BIAS * distanceChange;
if (newScale <= 1) {
setPosition({ x: 0, y: 0 });
setScale(newScale);
setZoomSpeed(ZOOM_SPEED);
return;
}
// Touch position relative to the container element
const touchX = (touch1.clientX + touch2.clientX) / 2 - rect.left - rect.width / 2;
const touchY = (touch1.clientY + touch2.clientY) / 2 - rect.top - rect.height / 2;
setPosition((prevPos) => {
return getZoomedOffset(touchX, touchY, scale, newScale, prevPos);
});
setScale(newScale);
setZoomSpeed(newZoom);
lastTouchDistance.current = currentDistance;
});
}
};
const onTouchEnd = (e: React.TouchEvent) => {
const touches = e.touches;
if (touches.length === 1) {
const touch = touches[0];
if (!touch) return;
dragStart.current = {
x: touch.clientX - position.x,
y: touch.clientY - position.y,
};
}
if (touches.length === 0) {
setIsDragging(false);
}
};
const handleReset = () => {
setScale(1);
setPosition({ x: 0, y: 0 });
setZoomSpeed(ZOOM_SPEED);
hideZoomLabelAfterDelay();
};
const onDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
if (scale === 1) {
const mouseX = e.clientX - rect.left - rect.width / 2;
const mouseY = e.clientY - rect.top - rect.height / 2;
const newScale = 2;
setPosition((prevPos) => {
return getZoomedOffset(mouseX, mouseY, scale, newScale, prevPos);
});
setScale(newScale);
hideZoomLabelAfterDelay();
} else {
handleReset();
}
};
const handleContainerClick = () => {
if (wasDragging.current) {
wasDragging.current = false;
return;
}
onClose();
};
const handleImageClick = (e: React.MouseEvent) => {
// Stop propagation to prevent closing when clicking on the image
e.stopPropagation();
// Don't toggle label if user was dragging
if (wasDragging.current) {
wasDragging.current = false;
return;
}
setShowZoomLabel((prev) => !prev);
};
const cursorStyle = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default';
if (!src) return null;
return (
<div
ref={containerRef}
tabIndex={-1}
role='button'
aria-label={_('Image viewer')}
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
onKeyDown={handleKeyDown}
onWheel={handleWheel}
onTouchMove={onTouchMove}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<div
role='button'
tabIndex={0}
className='not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClose();
}
}}
/>
<ZoomControls
onClose={onClose}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleReset}
/>
{onPrevious && showZoomLabel && (
<button
onClick={(e) => {
e.stopPropagation();
handlePreviousImage();
}}
className='eink-bordered absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white transition-all duration-300 hover:bg-black/70'
aria-label={_('Previous Image')}
title={_('Previous Image')}
>
<IoChevronBack className='h-8 w-8' />
</button>
)}
{onNext && showZoomLabel && (
<button
onClick={(e) => {
e.stopPropagation();
handleNextImage();
}}
className='eink-bordered absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white transition-all duration-300 hover:bg-black/70'
aria-label={_('Next Image')}
title={_('Next Image')}
>
<IoChevronForward className='h-8 w-8' />
</button>
)}
<div
role='none'
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden')}
onClick={handleContainerClick}
>
<Image
src={decodeURIComponent(src)}
ref={imageRef}
alt={_('Zoomed')}
className='transform-gpu select-none object-contain'
draggable={false}
width={0}
height={0}
sizes='100vw'
onClick={handleImageClick}
onMouseDown={handleImageMouseDown}
onMouseMove={handleImageMouseMove}
onMouseUp={handleImageMouseUp}
onMouseLeave={handleImageMouseUp}
onDoubleClick={onDoubleClick}
style={{
width: 'auto',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
transition: 'transform 0.05s ease-out',
cursor: cursorStyle,
}}
/>
</div>
{showZoomLabel && (
<div
aria-label={_('Zoom level')}
className='zoom-level-label eink-bordered not-eink:text-white not-eink:bg-black/50 pointer-events-none absolute left-1/2 top-12 -translate-x-1/2 rounded-full px-3 py-1 text-sm transition-opacity duration-300'
>
{Math.round((scale * 100) / 5) * 5}%
</div>
)}
</div>
);
};
export default ImageViewer;
@@ -0,0 +1,251 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import ZoomControls from './ZoomControls';
interface TableViewerProps {
html: string | null;
isDarkMode: boolean;
onClose: () => void;
}
const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const ZOOM_SPEED = 0.1;
const TableViewer: React.FC<TableViewerProps> = ({ html, isDarkMode, onClose }) => {
const _ = useTranslation();
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [showZoomLabel, setShowZoomLabel] = useState(true);
const dragStart = useRef({ x: 0, y: 0 });
const wasDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideZoomLabelAfterDelay = () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
}
setShowZoomLabel(true);
zoomLabelTimeoutRef.current = setTimeout(() => {
setShowZoomLabel(false);
}, 2000);
};
const handleZoomIn = () => {
const newScale = Math.min(scale + ZOOM_SPEED, MAX_SCALE);
setScale(newScale);
hideZoomLabelAfterDelay();
};
const handleZoomOut = () => {
const newScale = Math.max(scale - ZOOM_SPEED, MIN_SCALE);
if (newScale <= 1) {
setPosition({ x: 0, y: 0 });
setScale(newScale);
} else {
setScale(newScale);
}
hideZoomLabelAfterDelay();
};
const handleResetZoom = () => {
setScale(1);
setPosition({ x: 0, y: 0 });
hideZoomLabelAfterDelay();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
onClose();
return;
}
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
if (isCtrlOrCmd) {
e.preventDefault();
if (e.key === '=' || e.key === '+') {
handleZoomIn();
} else if (e.key === '-' || e.key === '_') {
handleZoomOut();
} else if (e.key === '0') {
handleResetZoom();
}
}
};
useEffect(() => {
containerRef.current?.focus();
setTimeout(() => {
hideZoomLabelAfterDelay();
}, 0);
return () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
}
};
}, []);
const handleWheel = (e: React.WheelEvent) => {
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
if (!isCtrlOrCmd) {
return;
}
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_SPEED : ZOOM_SPEED;
const newScale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
if (newScale <= 1) {
setPosition({ x: 0, y: 0 });
setScale(newScale);
hideZoomLabelAfterDelay();
return;
}
setScale(newScale);
hideZoomLabelAfterDelay();
};
const handleContentMouseDown = (e: React.MouseEvent) => {
if (isDragging || scale <= 1) return;
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
wasDragging.current = false;
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
};
const handleContentMouseMove = (e: React.MouseEvent) => {
if (!isDragging || scale <= 1) return;
e.preventDefault();
wasDragging.current = true;
const newX = e.clientX - dragStart.current.x;
const newY = e.clientY - dragStart.current.y;
setPosition({ x: newX, y: newY });
};
const handleContentMouseUp = (e: React.MouseEvent) => {
if (isDragging) {
e.stopPropagation();
}
setIsDragging(false);
};
const handleContainerClick = () => {
if (wasDragging.current) {
wasDragging.current = false;
return;
}
onClose();
};
const handleContentClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (wasDragging.current) {
wasDragging.current = false;
return;
}
setShowZoomLabel((prev) => !prev);
};
const cursorStyle = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default';
if (!html) return null;
return (
<div
ref={containerRef}
tabIndex={-1}
role='button'
aria-label={_('Table viewer')}
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
onKeyDown={handleKeyDown}
onWheel={handleWheel}
>
<div
role='button'
tabIndex={0}
className='absolute inset-0 bg-black/50 backdrop-blur-md'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClose();
}
}}
/>
<ZoomControls
onClose={onClose}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleResetZoom}
/>
<div
role='presentation'
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden')}
onClick={handleContainerClick}
>
<div
role='presentation'
ref={contentRef}
className='table-viewer-content max-h-full max-w-full transform-gpu select-none overflow-auto rounded-lg shadow-2xl'
onClick={handleContentClick}
onMouseDown={handleContentMouseDown}
onMouseMove={handleContentMouseMove}
onMouseUp={handleContentMouseUp}
onMouseLeave={handleContentMouseUp}
style={{
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
transition: 'transform 0.05s ease-out',
cursor: cursorStyle,
backgroundColor: isDarkMode ? '#1e1e1e' : '#ffffff',
color: isDarkMode ? '#ffffff' : '#000000',
padding: '24px',
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
{showZoomLabel && (
<div
aria-label={_('Zoom level')}
className='zoom-level-label eink-bordered not-eink:bg-black/50 not-eink:text-white pointer-events-none absolute left-1/2 top-12 -translate-x-1/2 rounded-full px-3 py-1 text-sm transition-opacity duration-300'
>
{Math.round((scale * 100) / 5) * 5}%
</div>
)}
<style jsx>{`
.table-viewer-content :global(table) {
border-collapse: collapse;
border: 1px solid ${isDarkMode ? '#444444' : '#cccccc'};
}
.table-viewer-content :global(td),
.table-viewer-content :global(th) {
border: 1px solid ${isDarkMode ? '#444444' : '#cccccc'};
padding: 8px 12px;
}
.table-viewer-content :global(th) {
background-color: ${isDarkMode ? '#2a2a2a' : '#f5f5f5'};
font-weight: 600;
}
`}</style>
</div>
);
};
export default TableViewer;
@@ -0,0 +1,55 @@
import React from 'react';
import { IoClose, IoExpand, IoAdd, IoRemove } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
interface ZoomControlsProps {
onClose: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
}
const ZoomControls: React.FC<ZoomControlsProps> = ({ onClose, onZoomIn, onZoomOut, onReset }) => {
const _ = useTranslation();
return (
<div className='absolute right-4 top-4 z-10 grid grid-cols-1 gap-4 text-white'>
<button
onClick={onClose}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Close')}
title={_('Close')}
>
<IoClose className='h-6 w-6' />
</button>
<button
onClick={onZoomIn}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Zoom In')}
title={_('Zoom In')}
>
<IoAdd className='h-6 w-6' />
</button>
<button
onClick={onZoomOut}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Zoom Out')}
title={_('Zoom Out')}
>
<IoRemove className='h-6 w-6' />
</button>
<button
onClick={onReset}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={_('Reset Zoom')}
title={_('Reset Zoom')}
>
<IoExpand className='h-6 w-6' />
</button>
</div>
);
};
export default ZoomControls;
@@ -55,6 +55,30 @@ export const useMouseEvent = (
};
};
export const useLongPressEvent = (
bookKey: string,
handleImagePress: (src: string) => void,
handleTablePress: (html: string) => void,
) => {
const handleLongPress = (msg: MessageEvent) => {
if (msg.data && msg.data.bookKey === bookKey && msg.data.type === 'iframe-long-press') {
if (msg.data.elementType === 'image') {
handleImagePress(msg.data.src);
} else if (msg.data.elementType === 'table') {
handleTablePress(msg.data.html);
}
}
};
useEffect(() => {
window.addEventListener('message', handleLongPress);
return () => {
window.removeEventListener('message', handleLongPress);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
};
interface IframeTouch {
clientX: number;
clientY: number;
@@ -267,3 +267,180 @@ export const handleTouchMove = (bookKey: string, event: TouchEvent) => {
export const handleTouchEnd = (bookKey: string, event: TouchEvent) => {
handleTouchEv(bookKey, event, 'iframe-touchend');
};
export const addLongPressListeners = (bookKey: string, doc: Document) => {
const longPressDuration = 500;
const moveThreshold = 10; // pixels - movement threshold to detect dragging/selection
const pressTimers = new Map<Element, ReturnType<typeof setTimeout>>();
const pressStartPositions = new Map<Element, { x: number; y: number }>();
const handleLongPress = (event: Event, target: HTMLElement) => {
event.preventDefault?.();
// Check if there's an active text selection - if so, don't trigger long-press
const selection = doc.getSelection();
if (selection && selection.toString().length > 0) {
return;
}
if (target.tagName === 'IMG') {
const imgTarget = target as HTMLImageElement;
window.postMessage(
{
type: 'iframe-long-press',
bookKey,
elementType: 'image',
src: imgTarget.src,
},
'*',
);
} else if (target.tagName === 'TABLE' || target.closest('table')) {
const tableTarget = (
target.tagName === 'TABLE' ? target : target.closest('table')
) as HTMLTableElement;
window.postMessage(
{
type: 'iframe-long-press',
bookKey,
elementType: 'table',
html: tableTarget.outerHTML,
},
'*',
);
}
};
const startPress = (event: Event) => {
const target = event.target as HTMLElement;
const isImage = target.tagName === 'IMG';
const isTableOrInTable = target.tagName === 'TABLE' || target.closest('table');
if (!isImage && !isTableOrInTable) return;
const elementToTrack = isImage
? target
: ((target.tagName === 'TABLE' ? target : target.closest('table')) as HTMLElement);
// Store initial position for movement detection
if ('clientX' in event && 'clientY' in event) {
const mouseEvent = event as MouseEvent;
pressStartPositions.set(elementToTrack, { x: mouseEvent.clientX, y: mouseEvent.clientY });
} else if ('touches' in event) {
const touchEvent = event as TouchEvent;
const touch = touchEvent.touches[0];
if (touch) {
pressStartPositions.set(elementToTrack, { x: touch.clientX, y: touch.clientY });
}
}
clearTimeout(pressTimers.get(elementToTrack));
const timer = setTimeout(() => handleLongPress(event, elementToTrack), longPressDuration);
pressTimers.set(elementToTrack, timer);
};
const handleMove = (event: Event) => {
const target = event.target as HTMLElement;
const isImage = target.tagName === 'IMG';
const isTableOrInTable = target.tagName === 'TABLE' || target.closest('table');
if (!isImage && !isTableOrInTable) return;
const elementToTrack = isImage
? target
: ((target.tagName === 'TABLE' ? target : target.closest('table')) as HTMLElement);
// Check if mouse/touch moved beyond threshold - if so, user is probably selecting text or dragging
const startPos = pressStartPositions.get(elementToTrack);
if (startPos) {
let currentX = 0;
let currentY = 0;
if ('clientX' in event && 'clientY' in event) {
const mouseEvent = event as MouseEvent;
currentX = mouseEvent.clientX;
currentY = mouseEvent.clientY;
} else if ('touches' in event) {
const touchEvent = event as TouchEvent;
const touch = touchEvent.touches[0];
if (touch) {
currentX = touch.clientX;
currentY = touch.clientY;
}
}
const distance = Math.sqrt(
Math.pow(currentX - startPos.x, 2) + Math.pow(currentY - startPos.y, 2),
);
// If moved beyond threshold, cancel the long-press
if (distance > moveThreshold) {
clearTimeout(pressTimers.get(elementToTrack));
pressTimers.delete(elementToTrack);
pressStartPositions.delete(elementToTrack);
}
}
};
const cancelPress = (event: Event) => {
const target = event.target as HTMLElement;
const isImage = target.tagName === 'IMG';
const isTableOrInTable = target.tagName === 'TABLE' || target.closest('table');
if (!isImage && !isTableOrInTable) return;
const elementToTrack = isImage
? target
: ((target.tagName === 'TABLE' ? target : target.closest('table')) as HTMLElement);
clearTimeout(pressTimers.get(elementToTrack));
pressTimers.delete(elementToTrack);
pressStartPositions.delete(elementToTrack);
};
const processElements = () => {
const images = doc.querySelectorAll('img');
const tables = doc.querySelectorAll('table');
images.forEach((img) => {
if (!img.hasAttribute('data-long-press-added')) {
img.setAttribute('data-long-press-added', 'true');
img.addEventListener('mousedown', startPress);
img.addEventListener('mousemove', handleMove);
img.addEventListener('mouseup', cancelPress);
img.addEventListener('mouseleave', cancelPress);
img.addEventListener('touchstart', startPress, { passive: true });
img.addEventListener('touchmove', handleMove, { passive: true });
img.addEventListener('touchend', cancelPress);
}
});
tables.forEach((table) => {
if (!table.hasAttribute('data-long-press-added')) {
table.setAttribute('data-long-press-added', 'true');
table.addEventListener('mousedown', startPress);
table.addEventListener('mousemove', handleMove);
table.addEventListener('mouseup', cancelPress);
table.addEventListener('mouseleave', cancelPress);
table.addEventListener('touchstart', startPress, { passive: true });
table.addEventListener('touchmove', handleMove, { passive: true });
table.addEventListener('touchend', cancelPress);
}
});
};
processElements();
const observer = new MutationObserver((mutations) => {
const hasNewElements = mutations.some((m) => m.type === 'childList' && m.addedNodes.length > 0);
if (hasNewElements) {
processElements();
}
});
observer.observe(doc.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
pressTimers.forEach((timer) => clearTimeout(timer));
};
};
+21
View File
@@ -283,3 +283,24 @@ export const getMimeTypeFromFileExt = (ext: string): string => {
}
return 'application/octet-stream';
};
export const convertBlobUrlToDataUrl = async (blobUrl: string): Promise<string> => {
try {
const response = await fetch(blobUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch blob from "${blobUrl}": ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error('Failed to convert blob to data URL:', error);
throw error;
}
};