fix(library): support dropping directories to import books (#4068)

Drag-and-drop now classifies dropped items into files vs directories.
Real files keep the existing import flow; dropped directories reuse
handleImportBooksFromDirectory via a new import-book-directory event,
matching the "From Directory" menu behavior instead of failing with
"No supported files found".

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-06 01:42:16 +08:00
committed by GitHub
parent c27245e980
commit 5dc2528455
4 changed files with 66 additions and 30 deletions
@@ -6,9 +6,14 @@ import { SelectedFile } from '@/hooks/useFileSelector';
import { isTauriAppPlatform } from '@/services/environment';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useTranslation } from '@/hooks/useTranslation';
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
import { useSearchParams } from 'next/navigation';
const hasSupportedBookExt = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
return ext ? SUPPORTED_BOOK_EXTS.includes(ext) : false;
};
export const useDragDropImport = () => {
const _ = useTranslation();
const searchParams = useSearchParams();
@@ -17,18 +22,27 @@ export const useDragDropImport = () => {
const { appService } = useEnv();
const [isDragging, setIsDragging] = useState(false);
const handleDroppedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
let fileExt;
if (typeof file === 'string') {
fileExt = file.split('.').pop()?.toLowerCase();
const handleDroppedFiles = async (droppedItems: File[] | string[]) => {
if (droppedItems.length === 0 || !appService) return;
const fileItems: (File | string)[] = [];
const directoryPaths: string[] = [];
for (const item of droppedItems) {
if (typeof item === 'string' && (await appService.isDirectory(item, 'None'))) {
directoryPaths.push(item);
} else {
fileExt = file.name.split('.').pop()?.toLowerCase();
fileItems.push(item);
}
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
});
if (supportedFiles.length === 0) {
}
const fileSelections: SelectedFile[] = fileItems
.filter((item) => hasSupportedBookExt(typeof item === 'string' ? item : item.name))
.map((item) => ({
file: typeof item === 'string' ? undefined : item,
path: typeof item === 'string' ? item : undefined,
}));
if (fileSelections.length === 0 && directoryPaths.length === 0) {
eventDispatcher.dispatch('toast', {
message: _('No supported files found. Supported formats: {{formats}}', {
formats: BOOK_ACCEPT_FORMATS,
@@ -38,18 +52,19 @@ export const useDragDropImport = () => {
return;
}
if (appService?.hasHaptics) {
if (appService.hasHaptics) {
impactFeedback('medium');
}
const selectedFiles = supportedFiles.map(
(file) =>
({
file: typeof file === 'string' ? undefined : file,
path: typeof file === 'string' ? file : undefined,
}) as SelectedFile,
);
eventDispatcher.dispatch('import-book-files', { files: selectedFiles, groupId: group });
if (fileSelections.length > 0) {
eventDispatcher.dispatch('import-book-files', {
files: fileSelections,
groupId: group,
});
}
for (const dir of directoryPaths) {
eventDispatcher.dispatch('import-book-directory', { path: dir });
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
+21 -10
View File
@@ -333,12 +333,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleImportBookDirectory = useCallback(async (event: CustomEvent) => {
const dirPath: string | undefined = event.detail?.path;
if (!dirPath) return;
await handleImportBooksFromDirectory(dirPath);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
eventDispatcher.on('import-book-files', handleImportBookFiles);
eventDispatcher.on('import-book-directory', handleImportBookDirectory);
return () => {
eventDispatcher.off('import-book-files', handleImportBookFiles);
eventDispatcher.off('import-book-directory', handleImportBookDirectory);
};
}, [handleImportBookFiles]);
}, [handleImportBookFiles, handleImportBookDirectory]);
useEffect(() => {
if (!libraryBooks.some((book) => !book.deletedAt)) {
@@ -802,19 +811,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
};
const handleImportBooksFromDirectory = async () => {
const handleImportBooksFromDirectory = async (dirPath?: string) => {
if (!appService || !isTauriAppPlatform()) return;
setIsSelectMode(false);
console.log('Importing books from directory...');
let importDirectory: string | undefined = '';
if (appService.isAndroidApp) {
if (!(await requestStoragePermission())) return;
const response = await selectDirectory();
importDirectory = response.path;
} else {
const selectedDir = await appService.selectDirectory?.('read');
importDirectory = selectedDir;
let importDirectory: string | undefined = dirPath;
if (!importDirectory) {
if (appService.isAndroidApp) {
if (!(await requestStoragePermission())) return;
const response = await selectDirectory();
importDirectory = response.path;
} else {
const selectedDir = await appService.selectDirectory?.('read');
importDirectory = selectedDir;
}
}
if (!importDirectory) {
console.log('No directory selected');
@@ -158,6 +158,15 @@ export abstract class BaseAppService implements AppService {
return await this.fs.exists(path, base);
}
async isDirectory(path: string, base: BaseDir): Promise<boolean> {
try {
const info = await this.fs.stats(path, base);
return info.isDirectory;
} catch {
return false;
}
}
async getImageURL(path: string): Promise<string> {
return await this.fs.getImageURL(path);
}
+1
View File
@@ -109,6 +109,7 @@ export interface AppService {
deleteFile(path: string, base: BaseDir): Promise<void>;
deleteDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
exists(path: string, base: BaseDir): Promise<boolean>;
isDirectory(path: string, base: BaseDir): Promise<boolean>;
getImageURL(path: string): Promise<string>;
setCustomRootDir(customRootDir: string): Promise<void>;