mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -13,6 +13,7 @@ function createElectronApiMock() {
|
||||
copyToClipboard: vi.fn(async () => ({ success: true })),
|
||||
getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })),
|
||||
automateUploadMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/abc.png', provider: options.provider })),
|
||||
automateUploadGeneratedMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/generated.png', provider: options.provider })),
|
||||
getPathForFile: vi.fn((): string | null => '/tmp/image.png'),
|
||||
};
|
||||
}
|
||||
@@ -67,6 +68,7 @@ describe('orchestrateElectronUpload', () => {
|
||||
it('fails with provider attempt details if no file path can be resolved', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.getPathForFile = vi.fn((): string | null => null);
|
||||
electronApi.automateUploadGeneratedMedia = undefined as unknown as typeof electronApi.automateUploadGeneratedMedia;
|
||||
window.electronApi = electronApi;
|
||||
|
||||
const file = new File(['z'], 'z.png', { type: 'image/png' });
|
||||
@@ -80,12 +82,30 @@ describe('orchestrateElectronUpload', () => {
|
||||
};
|
||||
expect(typedError.message).toBe('All providers failed');
|
||||
expect(typedError.attempts?.[0]?.provider).toBe('imgur');
|
||||
expect(typedError.attempts?.[0]?.error).toContain('File path required for Electron automation');
|
||||
expect(typedError.attempts?.[0]?.error).toContain('File path unavailable and automateUploadGeneratedMedia is not available');
|
||||
expect(typedError.attempts?.[0]?.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(typedError.attempts?.[0]?.stage).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses generated media automation when no file path can be resolved', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.getPathForFile = vi.fn((): string | null => null);
|
||||
window.electronApi = electronApi;
|
||||
|
||||
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
|
||||
const url = await orchestrateElectronUpload(file, ['imgur']);
|
||||
|
||||
expect(url).toBe('https://i.imgur.com/generated.png');
|
||||
expect(electronApi.automateUploadGeneratedMedia).toHaveBeenCalledWith({
|
||||
provider: 'imgur',
|
||||
fileName: 'tegaki.png',
|
||||
mimeType: 'image/png',
|
||||
bytes: [97, 98, 99],
|
||||
});
|
||||
expect(electronApi.automateUploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes stage and matchedSelectors when provider throws block/file-input errors', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('No file input found for imgur. Tried: input[type="file"], #upload'));
|
||||
|
||||
@@ -56,6 +56,10 @@ function resolveElectronFilePath(file: File): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fileToByteArray(file: File): Promise<number[]> {
|
||||
return Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file via a single provider. Catbox uses the web API;
|
||||
* imgur/imgbb use Electron automation when available.
|
||||
@@ -66,8 +70,21 @@ async function uploadViaProvider(provider: ProviderId, file: File): Promise<stri
|
||||
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
|
||||
if (fn) {
|
||||
const filePath = resolveElectronFilePath(file);
|
||||
if (!filePath) throw new Error('File path required for Electron automation');
|
||||
const { url } = await fn({ provider, filePath });
|
||||
if (filePath) {
|
||||
const { url } = await fn({ provider, filePath });
|
||||
return url;
|
||||
}
|
||||
|
||||
const generatedFn = window.electronApi?.automateUploadGeneratedMedia;
|
||||
if (!generatedFn) {
|
||||
throw new Error('File path unavailable and automateUploadGeneratedMedia is not available');
|
||||
}
|
||||
const { url } = await generatedFn({
|
||||
provider,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
bytes: await fileToByteArray(file),
|
||||
});
|
||||
return url;
|
||||
}
|
||||
throw new Error(`Provider ${provider} requires Electron (automateUploadMedia not available)`);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TegakiGlobal } from '../tegaki-loader';
|
||||
|
||||
const SCRIPT_SELECTOR = 'script[data-tegaki-oekaki="script"]';
|
||||
|
||||
const importFreshLoader = async () => {
|
||||
vi.resetModules();
|
||||
return import('../tegaki-loader');
|
||||
};
|
||||
|
||||
const createTegaki = (): TegakiGlobal => ({
|
||||
open: vi.fn(),
|
||||
flatten: vi.fn(() => document.createElement('canvas')),
|
||||
});
|
||||
|
||||
describe('loadTegaki', () => {
|
||||
afterEach(() => {
|
||||
delete window.Tegaki;
|
||||
document.head.querySelectorAll('[data-tegaki-oekaki]').forEach((element) => element.remove());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects immediately when an existing loaded script did not initialize Tegaki', async () => {
|
||||
const script = document.createElement('script');
|
||||
script.dataset.tegakiOekaki = 'script';
|
||||
Object.defineProperty(script, 'readyState', { configurable: true, value: 'complete' });
|
||||
document.head.appendChild(script);
|
||||
const { loadTegaki } = await importFreshLoader();
|
||||
|
||||
await expect(loadTegaki()).rejects.toThrow('Tegaki did not initialize');
|
||||
});
|
||||
|
||||
it('retries after a transient script load failure', async () => {
|
||||
const { loadTegaki } = await importFreshLoader();
|
||||
const firstLoad = loadTegaki();
|
||||
const firstScript = document.querySelector<HTMLScriptElement>(SCRIPT_SELECTOR);
|
||||
expect(firstScript).not.toBeNull();
|
||||
|
||||
firstScript?.dispatchEvent(new Event('error'));
|
||||
await expect(firstLoad).rejects.toThrow('Failed to load Tegaki');
|
||||
expect(document.querySelector(SCRIPT_SELECTOR)).toBeNull();
|
||||
|
||||
const secondLoad = loadTegaki();
|
||||
const secondScript = document.querySelector<HTMLScriptElement>(SCRIPT_SELECTOR);
|
||||
expect(secondScript).not.toBeNull();
|
||||
expect(secondScript).not.toBe(firstScript);
|
||||
|
||||
const tegaki = createTegaki();
|
||||
window.Tegaki = tegaki;
|
||||
secondScript?.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(secondLoad).resolves.toBe(tegaki);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const OEKAKI_WEB_DOWNLOAD_MESSAGE =
|
||||
'Auto-upload is not available on web because of CORS.\n\nDownload tegaki.png now? Upload it to some site, then paste the direct image link in the Link To File field.';
|
||||
|
||||
export const OEKAKI_WEB_WARNING_TEXT =
|
||||
'Auto-upload is not available on web because of CORS. Download tegaki.png, upload it to some site, then paste the direct image link in Link To File.';
|
||||
|
||||
export const OEKAKI_MOBILE_PORTRAIT_MESSAGE = 'Please rotate your device to draw.';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { resolveAssetUrl } from '../utils/preload-utils';
|
||||
|
||||
export const TEGAKI_DRAWING_FILE_NAME = 'tegaki.png';
|
||||
|
||||
interface TegakiOpenOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
saveReplay: boolean;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface TegakiGlobal {
|
||||
bg?: HTMLElement | null;
|
||||
open: (options: TegakiOpenOptions) => void;
|
||||
flatten: () => HTMLCanvasElement;
|
||||
destroy?: () => void;
|
||||
onOpenImageLoaded?: (this: HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Tegaki?: TegakiGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
const TEGAKI_ASSET_BASE = 'vendor/tegaki/0.9.4';
|
||||
const TEGAKI_SCRIPT_URL = resolveAssetUrl(`${TEGAKI_ASSET_BASE}/tegaki.min.js`);
|
||||
const TEGAKI_STYLESHEET_URL = resolveAssetUrl(`${TEGAKI_ASSET_BASE}/tegaki.css`);
|
||||
|
||||
let tegakiLoadPromise: Promise<TegakiGlobal> | null = null;
|
||||
|
||||
const ensureTegakiStylesheet = (): void => {
|
||||
if (document.querySelector('link[data-tegaki-oekaki="stylesheet"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = TEGAKI_STYLESHEET_URL;
|
||||
link.dataset.tegakiOekaki = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
const loadTegakiScript = (): Promise<TegakiGlobal> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (window.Tegaki) {
|
||||
resolve(window.Tegaki);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>('script[data-tegaki-oekaki="script"]');
|
||||
if (existingScript) {
|
||||
if (window.Tegaki) {
|
||||
resolve(window.Tegaki);
|
||||
return;
|
||||
}
|
||||
if (existingScript.dataset.failed === 'true') {
|
||||
existingScript.remove();
|
||||
} else {
|
||||
const readyState = (existingScript as HTMLScriptElement & { readyState?: string }).readyState;
|
||||
if (existingScript.dataset.loaded === 'true' || readyState === 'complete' || readyState === 'loaded') {
|
||||
reject(new Error('Tegaki did not initialize'));
|
||||
return;
|
||||
}
|
||||
existingScript.addEventListener('load', () => (window.Tegaki ? resolve(window.Tegaki) : reject(new Error('Tegaki did not initialize'))), { once: true });
|
||||
existingScript.addEventListener('error', () => reject(new Error('Failed to load Tegaki')), { once: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = TEGAKI_SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.dataset.tegakiOekaki = 'script';
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
if (window.Tegaki) {
|
||||
resolve(window.Tegaki);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Tegaki did not initialize'));
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.dataset.failed = 'true';
|
||||
script.remove();
|
||||
reject(new Error('Failed to load Tegaki'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
export const loadTegaki = (): Promise<TegakiGlobal> => {
|
||||
if (window.Tegaki) {
|
||||
ensureTegakiStylesheet();
|
||||
return Promise.resolve(window.Tegaki);
|
||||
}
|
||||
|
||||
if (!tegakiLoadPromise) {
|
||||
ensureTegakiStylesheet();
|
||||
tegakiLoadPromise = loadTegakiScript().catch((error) => {
|
||||
tegakiLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return tegakiLoadPromise;
|
||||
};
|
||||
@@ -82,6 +82,7 @@ const DIRECTORY_CODE_ORDER = [
|
||||
'wsg',
|
||||
'diy',
|
||||
'out',
|
||||
'i',
|
||||
'ic',
|
||||
'mu',
|
||||
'int',
|
||||
|
||||
Reference in New Issue
Block a user