xpaintdev / hooks /useAiFeatures.ts
suisuyy
Rename project back to xpaintai in package.json and metadata.json; add QuestionMarkIcon component; enhance AI features with ask functionality in useAiFeatures hook and App component
02ce812
import { useState, useCallback } from 'react';
import { ToastMessage } from './useToasts';
import { dataURLtoBlob, calculateSHA256, copyToClipboard } from '../utils/canvasUtils';
import { CanvasHistoryHook } from './useCanvasHistory';
const SHARE_API_URL = 'https://sharefile.suisuy.eu.org';
const ASK_API_URL = 'https://getai.deno.dev/';
const MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export type AiImageQuality = 'low' | 'medium' | 'hd';
export type AiDimensionsMode = 'api_default' | 'match_canvas' | 'fixed_1024';
interface AiFeaturesHook {
isMagicUploading: boolean;
showAiEditModal: boolean;
aiPrompt: string;
isGeneratingAiImage: boolean;
sharedImageUrlForAi: string | null;
aiEditError: string | null;
isAskingAi: boolean;
askUrl: string | null;
handleMagicUpload: () => Promise<void>;
handleGenerateAiImage: () => Promise<void>;
handleAskAi: () => Promise<void>;
handleCancelAiEdit: () => void;
setAiPrompt: React.Dispatch<React.SetStateAction<string>>;
clearAskUrl: () => void;
}
interface UseAiFeaturesProps {
currentDataURL: string | null;
showToast: (message: string, type: ToastMessage['type']) => void;
updateCanvasState: CanvasHistoryHook['updateCanvasState'];
setZoomLevel: (zoom: number) => void;
aiImageQuality: AiImageQuality;
aiApiEndpoint: string;
aiDimensionsMode: AiDimensionsMode;
currentCanvasWidth: number;
currentCanvasHeight: number;
}
export const useAiFeatures = ({
currentDataURL,
showToast,
updateCanvasState,
setZoomLevel,
aiImageQuality,
aiApiEndpoint,
aiDimensionsMode,
currentCanvasWidth,
currentCanvasHeight,
}: UseAiFeaturesProps): AiFeaturesHook => {
const [isMagicUploading, setIsMagicUploading] = useState<boolean>(false);
const [showAiEditModal, setShowAiEditModal] = useState<boolean>(false);
const [aiPrompt, setAiPrompt] = useState<string>('');
const [isGeneratingAiImage, setIsGeneratingAiImage] = useState<boolean>(false);
const [sharedImageUrlForAi, setSharedImageUrlForAi] = useState<string | null>(null);
const [aiEditError, setAiEditError] = useState<string | null>(null);
const [isAskingAi, setIsAskingAi] = useState<boolean>(false);
const [askUrl, setAskUrl] = useState<string | null>(null);
const clearAskUrl = useCallback(() => {
setAskUrl(null);
}, []);
const loadAiImageOntoCanvas = useCallback((aiImageDataUrl: string) => {
const img = new Image();
img.onload = () => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = currentCanvasWidth;
tempCanvas.height = currentCanvasHeight;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
// Fill background (e.g., white)
tempCtx.fillStyle = '#FFFFFF';
tempCtx.fillRect(0, 0, currentCanvasWidth, currentCanvasHeight);
let drawWidth = img.naturalWidth;
let drawHeight = img.naturalHeight;
// Scale image if it's larger than the canvas, preserving aspect ratio
if (img.naturalWidth > currentCanvasWidth || img.naturalHeight > currentCanvasHeight) {
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (currentCanvasWidth / aspectRatio <= currentCanvasHeight) {
drawWidth = currentCanvasWidth;
drawHeight = currentCanvasWidth / aspectRatio;
} else {
drawHeight = currentCanvasHeight;
drawWidth = currentCanvasHeight * aspectRatio;
}
}
// Ensure dimensions are at least 1px
drawWidth = Math.max(1, Math.floor(drawWidth));
drawHeight = Math.max(1, Math.floor(drawHeight));
// Change: Draw image at top-left (0,0)
const drawX = 0;
const drawY = 0;
tempCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
const newCanvasState = tempCanvas.toDataURL('image/png');
// Update canvas state with the new image, but keep original canvas dimensions
updateCanvasState(newCanvasState, currentCanvasWidth, currentCanvasHeight);
setShowAiEditModal(false);
showToast('AI image applied to canvas!', 'success');
setAiPrompt('');
setSharedImageUrlForAi(null);
} else {
setAiEditError('Failed to create drawing context for AI image.');
}
setIsGeneratingAiImage(false);
};
img.onerror = () => {
setAiEditError('Failed to load the generated AI image. It might be an invalid image format.');
setIsGeneratingAiImage(false);
};
img.crossOrigin = "anonymous";
img.src = aiImageDataUrl;
}, [showToast, updateCanvasState, currentCanvasWidth, currentCanvasHeight]);
const handleMagicUpload = useCallback(async () => {
if (isMagicUploading || !currentDataURL) {
showToast('No canvas content to share.', 'info');
return;
}
setIsMagicUploading(true);
setAiEditError(null);
showToast('Uploading image...', 'info');
try {
const blob = await dataURLtoBlob(currentDataURL);
if (blob.size > MAX_UPLOAD_SIZE_BYTES) {
showToast(`Image too large (${(blob.size / 1024 / 1024).toFixed(2)}MB). Max 50MB.`, 'error');
setIsMagicUploading(false);
return;
}
const hash = await calculateSHA256(blob);
const filename = `tempaint_${hash}.png`;
const uploadUrl = `${SHARE_API_URL}/${filename}`;
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': blob.type || 'image/png' },
body: blob,
});
if (!response.ok) {
let errorMsg = `Upload failed: ${response.statusText}`;
try { const errorBody = await response.text(); errorMsg = `Upload failed: ${errorBody || response.statusText}`; } catch (e) { /* ignore */ }
throw new Error(errorMsg);
}
const returnedObjectName = await response.text();
const finalShareUrl = `https://pub-cb2c87ea7373408abb1050dd43e3cd8e.r2.dev/${returnedObjectName}`;
setSharedImageUrlForAi(finalShareUrl);
setShowAiEditModal(true);
setAiPrompt('');
setAiEditError(null);
clearAskUrl();
} catch (error: any) {
console.error('Magic upload error:', error);
showToast(error.message || 'Magic upload failed. Check console.', 'error');
} finally {
setIsMagicUploading(false);
}
}, [isMagicUploading, currentDataURL, showToast, clearAskUrl]);
const handleGenerateAiImage = useCallback(async () => {
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
setAiEditError('Please enter a prompt and ensure an image was uploaded.');
return;
}
setIsGeneratingAiImage(true);
setAiEditError(null);
clearAskUrl();
showToast('Generating AI image...', 'info');
const encodedPrompt = encodeURIComponent(aiPrompt);
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
let finalApiUrl = aiApiEndpoint
.replace('{prompt}', encodedPrompt)
.replace('{imgurl.url}', encodedImageUrl);
if (finalApiUrl.includes('{quality}')) {
finalApiUrl = finalApiUrl.replace('{quality}', aiImageQuality);
}
if (finalApiUrl.includes('{size_params}')) {
let sizeParamsString = '';
if (aiDimensionsMode === 'match_canvas') {
sizeParamsString = `&width=${currentCanvasWidth}&height=${currentCanvasHeight}`;
} else if (aiDimensionsMode === 'fixed_1024') {
sizeParamsString = `&width=1024&height=1024`;
}
finalApiUrl = finalApiUrl.replace('{size_params}', sizeParamsString);
}
try {
const response = await fetch(finalApiUrl);
if (!response.ok) {
let errorMsg = `AI image generation failed: ${response.status} ${response.statusText}`;
try {
const errorBody = await response.text();
if (errorBody && !errorBody.toLowerCase().includes('<html')) {
errorMsg += ` - ${errorBody.substring(0,100)}`;
}
} catch(e) { /* ignore if can't read body */ }
throw new Error(errorMsg);
}
const imageBlob = await response.blob();
if (!imageBlob.type.startsWith('image/')) {
throw new Error('AI service did not return a valid image. Please try a different prompt or check the API endpoint.');
}
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === 'string') {
loadAiImageOntoCanvas(reader.result);
} else {
setAiEditError('Failed to read AI image data as string.');
setIsGeneratingAiImage(false);
}
};
reader.onerror = () => {
setAiEditError('Failed to read AI image data.');
setIsGeneratingAiImage(false);
}
reader.readAsDataURL(imageBlob);
} catch (error: any) {
console.error('AI image generation error:', error);
setAiEditError(error.message || 'An unknown error occurred during AI image generation.');
setIsGeneratingAiImage(false);
}
}, [aiPrompt, sharedImageUrlForAi, showToast, loadAiImageOntoCanvas, aiImageQuality, aiApiEndpoint, aiDimensionsMode, currentCanvasWidth, currentCanvasHeight, clearAskUrl]);
const handleAskAi = useCallback(async () => {
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
setAiEditError('Please enter a question about the image.');
return;
}
setIsAskingAi(true);
setAiEditError(null);
setAskUrl(null); // Clear previous URL first
try {
const encodedPrompt = encodeURIComponent(aiPrompt);
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
const finalAskUrl = `${ASK_API_URL}?q=${encodedPrompt}&image=${encodedImageUrl}`;
setAskUrl(finalAskUrl);
} catch (error: any) {
console.error('AI ask error:', error);
setAiEditError(error.message || 'An unknown error occurred while asking AI.');
} finally {
setIsAskingAi(false);
}
}, [aiPrompt, sharedImageUrlForAi]);
const handleCancelAiEdit = () => {
setShowAiEditModal(false);
if (sharedImageUrlForAi) {
copyToClipboard(sharedImageUrlForAi, (msg, type) => showToast(msg,type as 'info' | 'error')).then(copied => {
if(copied) {
showToast(`Image uploaded! URL: ${sharedImageUrlForAi} (Copied!)`, 'success');
}
});
}
setAiPrompt('');
setAiEditError(null);
setSharedImageUrlForAi(null);
clearAskUrl();
};
return {
isMagicUploading,
showAiEditModal,
aiPrompt,
isGeneratingAiImage,
sharedImageUrlForAi,
aiEditError,
isAskingAi,
askUrl,
handleMagicUpload,
handleGenerateAiImage,
handleAskAi,
handleCancelAiEdit,
setAiPrompt,
clearAskUrl,
};
};