feat(oekaki): add drawing flow for /i/ (#1144)

* feat(oekaki): add drawing flow for /i/

* fix(oekaki): address review feedback

* fix(oekaki): reset Tegaki edit sessions

* fix(oekaki): block drawing during export

* fix(oekaki): destroy Tegaki on preload errors

* fix(oekaki): preserve drawing on export failure

* fix(oekaki): unlock controls after export failure
This commit is contained in:
Tommaso Casaburi
2026-05-30 15:06:47 +07:00
committed by GitHub
parent 5a0b4909b8
commit 56894700c1
28 changed files with 2134 additions and 49 deletions
+72 -1
View File
@@ -20,7 +20,7 @@ vi.mock('@capacitor/core', () => ({
}));
vi.mock('../../plugins/file-uploader', () => ({
default: { pickAndUploadMedia: vi.fn() },
default: { pickAndUploadMedia: vi.fn(), uploadGeneratedMedia: vi.fn() },
}));
vi.mock('../../lib/utils/catbox-utils', () => ({
@@ -211,6 +211,77 @@ describe('useFileUpload', () => {
expect(hook().isUploading).toBe(false);
});
it('uploads a generated file via Electron orchestrator', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('web');
window.electronApi = { isElectron: true } as any;
vi.mocked(orchestrateElectronUpload).mockResolvedValue('https://files.catbox.moe/tegaki.png');
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const { onUploadComplete, hook } = mountHook();
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
await act(async () => {
result = await hook().uploadFile(file);
});
expect(orchestrateElectronUpload).toHaveBeenCalledWith(file, ['catbox']);
expect(result).toEqual({ url: 'https://files.catbox.moe/tegaki.png', fileName: 'tegaki.png' });
expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/tegaki.png', 'tegaki.png');
expect(hook().uploadedFileName).toBe('tegaki.png');
expect(hook().isUploading).toBe(false);
});
it('uploads a generated file via Android plugin', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
vi.mocked(FileUploader.uploadGeneratedMedia).mockResolvedValue({
url: 'https://files.catbox.moe/android-tegaki.png',
fileName: 'tegaki.png',
provider: 'catbox',
});
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const { onUploadComplete, hook } = mountHook();
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
await act(async () => {
result = await hook().uploadFile(file);
});
expect(FileUploader.uploadGeneratedMedia).toHaveBeenCalledWith({
providerOrder: ['catbox'],
fileName: 'tegaki.png',
mimeType: 'image/png',
base64: 'YWJj',
});
expect(result).toEqual({ url: 'https://files.catbox.moe/android-tegaki.png', fileName: 'tegaki.png' });
expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/android-tegaki.png', 'tegaki.png');
expect(hook().uploadedFileName).toBe('tegaki.png');
});
it('does not upload generated files from web runtime', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('web');
window.electronApi = undefined;
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const { onUploadComplete, hook } = mountHook();
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
await act(async () => {
result = await hook().uploadFile(file);
});
expect(result).toBeNull();
expect(window.alert).toHaveBeenCalledWith('upload_not_supported_web');
expect(FileUploader.uploadGeneratedMedia).not.toHaveBeenCalled();
expect(orchestrateElectronUpload).not.toHaveBeenCalled();
expect(onUploadComplete).not.toHaveBeenCalled();
});
it('silently ignores file selection cancellation', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
+109 -34
View File
@@ -27,6 +27,7 @@ const ANDROID_STAGE_MAP: Record<string, UploadAttemptStage> = {
};
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
const WEB_UPLOAD_NOT_SUPPORTED_ERROR = 'Web upload is not supported';
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'imgbb'];
@@ -88,6 +89,24 @@ interface UseFileUploadOptions {
onUploadComplete: (url: string, fileName: string) => void;
}
export interface UploadedFileResult {
url: string;
fileName: string;
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('Could not read file'));
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const marker = result.indexOf(',');
resolve(marker >= 0 ? result.slice(marker + 1) : result);
};
reader.readAsDataURL(file);
});
}
function selectFileViaInput(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
@@ -151,32 +170,97 @@ export function useFileUpload(options: UseFileUploadOptions) {
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const getAvailableProviderOrder = useCallback(async () => {
const runtime = getMediaHostingRuntime();
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
const availability = await ensureProviderAvailability(runtime);
const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime, availability });
if (order.length === 0) {
if (runtime === 'web') {
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
}
if (supportedOrder.length > 0) {
const message =
uploadMode === 'preferred' && availability[preferredProvider] === 'unavailable'
? `${preferredProvider} is unavailable from this network`
: 'No reachable upload providers are available from this network';
throw new Error(message);
}
throw new Error(`${preferredProvider} is not supported on ${runtime}`);
}
return { runtime, order };
}, [uploadMode, preferredProvider]);
const handleUploadError = useCallback(
(error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
if (errorMessage === WEB_UPLOAD_NOT_SUPPORTED_ERROR) {
window.alert(t('upload_not_supported_web'));
return;
}
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
if (err.attempts && err.attempts.length > 0) {
window.alert(formatAggregatedError(err.attempts, t));
} else if (uploadMode === 'preferred') {
window.alert(formatPreferredModeError(errorMessage, t));
} else {
window.alert(`${t('upload_failed')}: ${errorMessage}`);
}
},
[t, uploadMode],
);
const uploadFile = useCallback(
async (file: File): Promise<UploadedFileResult | null> => {
if (uploadMode === 'none') return null;
try {
setIsUploading(true);
setUploadedFileName(null);
const { runtime, order } = await getAvailableProviderOrder();
let result: UploadedFileResult | null = null;
if (runtime === 'android') {
const pluginResult = await FileUploader.uploadGeneratedMedia({
providerOrder: order,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
base64: await readFileAsBase64(file),
});
result = pluginResult.url ? { url: pluginResult.url, fileName: pluginResult.fileName || file.name } : null;
} else if (runtime === 'electron') {
const url = await orchestrateElectronUpload(file, order);
result = { url, fileName: file.name };
} else {
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
}
if (result?.url) {
setUploadedFileName(result.fileName);
onUploadComplete(result.url, result.fileName);
}
return result;
} catch (error) {
handleUploadError(error);
return null;
} finally {
setIsUploading(false);
}
},
[getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode],
);
const handleUpload = useCallback(async () => {
if (uploadMode === 'none') return;
const runtime = getMediaHostingRuntime();
try {
setIsUploading(true);
setUploadedFileName(null);
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
const availability = await ensureProviderAvailability(runtime);
const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime, availability });
if (order.length === 0) {
if (runtime === 'web') {
window.alert(t('upload_not_supported_web'));
return;
}
if (supportedOrder.length > 0) {
const message =
uploadMode === 'preferred' && availability[preferredProvider] === 'unavailable'
? `${preferredProvider} is unavailable from this network`
: 'No reachable upload providers are available from this network';
throw new Error(message);
}
throw new Error(`${preferredProvider} is not supported on ${runtime}`);
}
const { runtime, order } = await getAvailableProviderOrder();
if (runtime === 'android') {
const result = await FileUploader.pickAndUploadMedia({ providerOrder: order });
if (result.url) {
@@ -186,7 +270,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
return;
}
if (isElectronRuntime()) {
if (runtime === 'electron' || isElectronRuntime()) {
const file = await selectFileViaInput();
if (!file) {
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
@@ -198,27 +282,18 @@ export function useFileUpload(options: UseFileUploadOptions) {
return;
}
window.alert(t('upload_not_supported_web'));
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
if (err.attempts && err.attempts.length > 0) {
window.alert(formatAggregatedError(err.attempts, t));
} else if (uploadMode === 'preferred') {
window.alert(formatPreferredModeError(errorMessage, t));
} else {
window.alert(`${t('upload_failed')}: ${errorMessage}`);
}
handleUploadError(error);
} finally {
setIsUploading(false);
}
}, [onUploadComplete, t, uploadMode, preferredProvider]);
}, [getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode]);
return {
isUploading,
uploadedFileName,
handleUpload,
uploadFile,
};
}