945780def8
feat: optimize dashboard/search UX and simplify extension architecture Redesign Dashboard with compact tool grid and recently used tools Improve TopBar search UX with Cmd/Ctrl+K shortcut and better history navigation Reorganize project structure into src/ Migrate i18n from react-i18next to chrome.i18n Remove runtime language switch and settings page Remove HTML/Markdown conversion tools Clean up unused code, dead animations, redundant comments, and imports Improve component consistency with shadcn/ui patterns Replace hardcoded strings/colors with i18n tokens and theme tokens Add comprehensive project documentation and coding standards Fix CI artifact upload workflow and multiple TypeScript/test issues Includes various refactors, UI polish, i18n cleanup, CI improvements, and maintenance updates across the codebase.
154 lines
4.1 KiB
TypeScript
154 lines
4.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useI18n } from '@/utils/chromeI18n';
|
|
import type { FileToBase64Result } from '@/utils/base64Converter';
|
|
import {
|
|
base64ToBlob,
|
|
fileToBase64,
|
|
isFileSizeValid,
|
|
isSupportedImageExtension,
|
|
isSupportedImageType,
|
|
MAX_FILE_SIZE,
|
|
} from '@/utils/base64Converter';
|
|
|
|
interface FileInfo {
|
|
name: string;
|
|
size: number;
|
|
type: string;
|
|
}
|
|
|
|
interface UseBase64ConverterProps {
|
|
mode: 'file' | 'image';
|
|
}
|
|
|
|
export function useBase64Converter({ mode }: UseBase64ConverterProps) {
|
|
const { t } = useI18n('base64Converter');
|
|
|
|
const [result, setResult] = useState<FileToBase64Result | null>(null);
|
|
const [info, setInfo] = useState<FileInfo | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const cancelRef = useRef(false);
|
|
|
|
const [decodeInput, setDecodeInput] = useState('');
|
|
const [debouncedDecodeInput, setDebouncedDecodeInput] = useState('');
|
|
const [encodeError, setEncodeError] = useState<string | null>(null);
|
|
const [customFileName, setCustomFileName] = useState('');
|
|
|
|
useEffect(() => {
|
|
const handle = setTimeout(() => {
|
|
setDebouncedDecodeInput(decodeInput);
|
|
}, 250);
|
|
return () => clearTimeout(handle);
|
|
}, [decodeInput]);
|
|
|
|
const resetAll = useCallback(() => {
|
|
cancelRef.current = true;
|
|
setResult(null);
|
|
setInfo(null);
|
|
setIsLoading(false);
|
|
setDecodeInput('');
|
|
setDebouncedDecodeInput('');
|
|
setCustomFileName('');
|
|
setEncodeError(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
}, []);
|
|
|
|
const handleFileSelect = useCallback(
|
|
async (file: File) => {
|
|
cancelRef.current = false;
|
|
setEncodeError(null);
|
|
setResult(null);
|
|
setInfo(null);
|
|
|
|
if (!isFileSizeValid(file.size)) {
|
|
setEncodeError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` }));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
mode === 'image' &&
|
|
!isSupportedImageType(file.type) &&
|
|
!isSupportedImageExtension(file.name)
|
|
) {
|
|
setEncodeError(t('unsupportedImageType'));
|
|
return;
|
|
}
|
|
|
|
setInfo({
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type || 'application/octet-stream',
|
|
});
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const res = await fileToBase64(file);
|
|
if (!cancelRef.current) setResult(res);
|
|
} catch (e) {
|
|
if (!cancelRef.current) {
|
|
setEncodeError(e instanceof Error ? e.message : t('conversionFailed'));
|
|
}
|
|
} finally {
|
|
if (!cancelRef.current) setIsLoading(false);
|
|
}
|
|
},
|
|
[mode, t],
|
|
);
|
|
|
|
const safeFileSelect = useCallback(
|
|
(file: File) => {
|
|
handleFileSelect(file).catch((err) => {
|
|
console.error(`Base64 [${mode}] pipeline crash:`, err);
|
|
});
|
|
},
|
|
[handleFileSelect, mode],
|
|
);
|
|
|
|
const decodePipeline = useMemo(() => {
|
|
const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim();
|
|
if (!cleanedInput) return { decoded: null, error: null };
|
|
|
|
try {
|
|
const res = base64ToBlob(cleanedInput);
|
|
return { decoded: res, error: null };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : '';
|
|
return {
|
|
decoded: null,
|
|
error: message === 'Invalid Base64 string' ? t('invalidBase64') : t('conversionFailed'),
|
|
};
|
|
}
|
|
}, [debouncedDecodeInput, t]);
|
|
|
|
const decoded = decodePipeline.decoded;
|
|
const decodeError = decodePipeline.error;
|
|
|
|
const decodedFileName = useMemo(() => {
|
|
if (customFileName) return customFileName;
|
|
if (decoded) return `decoded${decoded.suggestedExtension}`;
|
|
return '';
|
|
}, [customFileName, decoded]);
|
|
|
|
const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`;
|
|
|
|
return {
|
|
result,
|
|
info,
|
|
isLoading,
|
|
isDragging,
|
|
setIsDragging,
|
|
fileInputRef,
|
|
encodeError,
|
|
decodeInput,
|
|
setDecodeInput,
|
|
decoded,
|
|
decodeError,
|
|
decodedFileName,
|
|
setCustomFileName,
|
|
resetAll,
|
|
safeFileSelect,
|
|
maxFileSizeStr,
|
|
};
|
|
}
|