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, | |
| }; | |
| }; | |