suisuyy
commited on
Commit
·
763be49
1
Parent(s):
722075a
Initialize xpaintai project with core files and basic structure
Browse files- .gitignore +21 -20
- App.tsx +237 -0
- README.md +9 -78
- apidoc/fileworker.js +229 -0
- components/AiEditModal.tsx +113 -0
- components/CanvasComponent.tsx +202 -0
- components/ConfirmModal.tsx +74 -0
- components/SettingsPanel.tsx +213 -0
- components/Toolbar.tsx +177 -0
- components/ZoomSlider.tsx +43 -0
- components/icons.tsx +74 -0
- constants.ts +9 -0
- hooks/useAiFeatures.ts +269 -0
- hooks/useCanvasFileUtils.ts +143 -0
- hooks/useCanvasHistory.ts +114 -0
- hooks/useConfirmModal.ts +73 -0
- hooks/useDrawingTools.ts +34 -0
- hooks/useFullscreen.ts +85 -0
- hooks/useToasts.ts +23 -0
- index.html +23 -0
- index.tsx +15 -0
- metadata.json +6 -0
- package.json +13 -32
- services/dbService.ts +122 -0
- tsconfig.json +30 -0
- types.ts +3 -0
- utils/canvasUtils.ts +42 -0
- vite.config.ts +17 -0
.gitignore
CHANGED
|
@@ -1,23 +1,24 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
/node_modules
|
| 5 |
-
/.pnp
|
| 6 |
-
.pnp.js
|
| 7 |
-
|
| 8 |
-
# testing
|
| 9 |
-
/coverage
|
| 10 |
-
|
| 11 |
-
# production
|
| 12 |
-
/build
|
| 13 |
-
|
| 14 |
-
# misc
|
| 15 |
-
.DS_Store
|
| 16 |
-
.env.local
|
| 17 |
-
.env.development.local
|
| 18 |
-
.env.test.local
|
| 19 |
-
.env.production.local
|
| 20 |
-
|
| 21 |
npm-debug.log*
|
| 22 |
yarn-debug.log*
|
| 23 |
yarn-error.log*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
npm-debug.log*
|
| 5 |
yarn-debug.log*
|
| 6 |
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
App.tsx
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useCallback } from 'react';
|
| 2 |
+
import Toolbar from './components/Toolbar';
|
| 3 |
+
import CanvasComponent from './components/CanvasComponent';
|
| 4 |
+
import AiEditModal from './components/AiEditModal';
|
| 5 |
+
import ZoomSlider from './components/ZoomSlider';
|
| 6 |
+
import SettingsPanel from './components/SettingsPanel';
|
| 7 |
+
import ConfirmModal from './components/ConfirmModal';
|
| 8 |
+
import { DEFAULT_AI_API_ENDPOINT } from './constants';
|
| 9 |
+
|
| 10 |
+
// Custom Hooks
|
| 11 |
+
import { useToasts } from './hooks/useToasts';
|
| 12 |
+
import { useCanvasHistory } from './hooks/useCanvasHistory';
|
| 13 |
+
import { useAiFeatures, AiImageQuality } from './hooks/useAiFeatures';
|
| 14 |
+
import { useDrawingTools } from './hooks/useDrawingTools';
|
| 15 |
+
import { useFullscreen } from './hooks/useFullscreen';
|
| 16 |
+
import { useConfirmModal } from './hooks/useConfirmModal';
|
| 17 |
+
import { useCanvasFileUtils } from './hooks/useCanvasFileUtils';
|
| 18 |
+
|
| 19 |
+
const App: React.FC = () => {
|
| 20 |
+
// Core UI States
|
| 21 |
+
const [zoomLevel, setZoomLevel] = useState<number>(0.5);
|
| 22 |
+
const [showSettingsPanel, setShowSettingsPanel] = useState<boolean>(false);
|
| 23 |
+
|
| 24 |
+
// Settings specific states
|
| 25 |
+
const [showZoomSlider, setShowZoomSlider] = useState<boolean>(true);
|
| 26 |
+
const [aiImageQuality, setAiImageQuality] = useState<AiImageQuality>('low');
|
| 27 |
+
const [aiApiEndpoint, setAiApiEndpoint] = useState<string>(DEFAULT_AI_API_ENDPOINT);
|
| 28 |
+
|
| 29 |
+
// Custom Hooks Instantiation
|
| 30 |
+
const { toasts, showToast } = useToasts();
|
| 31 |
+
const {
|
| 32 |
+
historyStack,
|
| 33 |
+
currentHistoryIndex,
|
| 34 |
+
canvasWidth,
|
| 35 |
+
canvasHeight,
|
| 36 |
+
isLoading: isCanvasLoading,
|
| 37 |
+
currentDataURL,
|
| 38 |
+
handleDrawEnd,
|
| 39 |
+
handleUndo,
|
| 40 |
+
handleRedo,
|
| 41 |
+
updateCanvasState,
|
| 42 |
+
} = useCanvasHistory();
|
| 43 |
+
|
| 44 |
+
const {
|
| 45 |
+
penColor,
|
| 46 |
+
setPenColor,
|
| 47 |
+
penSize,
|
| 48 |
+
setPenSize,
|
| 49 |
+
isEraserMode,
|
| 50 |
+
toggleEraserMode,
|
| 51 |
+
effectivePenColor,
|
| 52 |
+
} = useDrawingTools();
|
| 53 |
+
|
| 54 |
+
const { isFullscreenActive, toggleFullscreen } = useFullscreen(showToast);
|
| 55 |
+
const { confirmModalInfo, requestConfirmation, closeConfirmModal } = useConfirmModal();
|
| 56 |
+
|
| 57 |
+
const {
|
| 58 |
+
handleLoadImageFile,
|
| 59 |
+
handleExportImage,
|
| 60 |
+
handleClearCanvas,
|
| 61 |
+
handleCanvasSizeChange,
|
| 62 |
+
} = useCanvasFileUtils({
|
| 63 |
+
canvasState: { currentDataURL, canvasWidth, canvasHeight },
|
| 64 |
+
historyActions: { updateCanvasState },
|
| 65 |
+
uiActions: { showToast, setZoomLevel },
|
| 66 |
+
confirmModalActions: { requestConfirmation },
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
const {
|
| 70 |
+
isMagicUploading,
|
| 71 |
+
showAiEditModal,
|
| 72 |
+
aiPrompt,
|
| 73 |
+
isGeneratingAiImage,
|
| 74 |
+
sharedImageUrlForAi,
|
| 75 |
+
aiEditError,
|
| 76 |
+
handleMagicUpload,
|
| 77 |
+
handleGenerateAiImage,
|
| 78 |
+
handleCancelAiEdit,
|
| 79 |
+
setAiPrompt,
|
| 80 |
+
} = useAiFeatures({
|
| 81 |
+
currentDataURL,
|
| 82 |
+
showToast,
|
| 83 |
+
updateCanvasState,
|
| 84 |
+
setZoomLevel,
|
| 85 |
+
aiImageQuality,
|
| 86 |
+
aiApiEndpoint,
|
| 87 |
+
currentCanvasWidth: canvasWidth, // Pass current canvas dimensions
|
| 88 |
+
currentCanvasHeight: canvasHeight, // Pass current canvas dimensions
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
// Simple UI Toggles
|
| 92 |
+
const handleToggleSettingsPanel = () => setShowSettingsPanel(prev => !prev);
|
| 93 |
+
const canShare = !!currentDataURL;
|
| 94 |
+
|
| 95 |
+
// Loading State
|
| 96 |
+
if (isCanvasLoading) {
|
| 97 |
+
return (
|
| 98 |
+
<div className="flex justify-center items-center h-screen bg-gradient-to-br from-slate-200 to-sky-100">
|
| 99 |
+
<div className="text-center p-8 bg-white/50 backdrop-blur-md rounded-xl shadow-2xl">
|
| 100 |
+
<svg className="animate-spin h-12 w-12 text-blue-600 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 101 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 102 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 103 |
+
</svg>
|
| 104 |
+
<p className="text-2xl font-semibold text-slate-700">Loading Your Canvas...</p>
|
| 105 |
+
<p className="text-sm text-slate-500 mt-1">Getting things ready!</p>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Main App Render
|
| 112 |
+
return (
|
| 113 |
+
<div className="min-h-screen flex flex-col items-center bg-gradient-to-br from-slate-100 to-sky-200 font-sans">
|
| 114 |
+
<Toolbar
|
| 115 |
+
penColor={penColor}
|
| 116 |
+
onColorChange={setPenColor}
|
| 117 |
+
penSize={penSize}
|
| 118 |
+
onSizeChange={setPenSize}
|
| 119 |
+
onUndo={handleUndo}
|
| 120 |
+
canUndo={currentHistoryIndex > 0}
|
| 121 |
+
onRedo={handleRedo}
|
| 122 |
+
canRedo={currentHistoryIndex < historyStack.length - 1 && historyStack.length > 0}
|
| 123 |
+
onLoadImage={handleLoadImageFile}
|
| 124 |
+
onExportImage={handleExportImage}
|
| 125 |
+
onClearCanvas={handleClearCanvas}
|
| 126 |
+
isEraserMode={isEraserMode}
|
| 127 |
+
onToggleEraser={toggleEraserMode}
|
| 128 |
+
onMagicUpload={handleMagicUpload}
|
| 129 |
+
isMagicUploading={isMagicUploading}
|
| 130 |
+
canShare={canShare}
|
| 131 |
+
onToggleSettings={handleToggleSettingsPanel}
|
| 132 |
+
/>
|
| 133 |
+
<main className="flex-grow grid place-items-center p-4 md:p-6 w-full mt-4 mb-4 overflow-auto">
|
| 134 |
+
<div
|
| 135 |
+
style={{
|
| 136 |
+
transform: `scale(${zoomLevel})`,
|
| 137 |
+
transformOrigin: 'top left',
|
| 138 |
+
transition: 'transform 0.1s ease-out'
|
| 139 |
+
}}
|
| 140 |
+
aria-label={`Canvas zoomed to ${Math.round(zoomLevel * 100)}%`}
|
| 141 |
+
>
|
| 142 |
+
{currentDataURL ? (
|
| 143 |
+
<CanvasComponent
|
| 144 |
+
key={`canvas-comp-${canvasWidth}-${canvasHeight}-${currentHistoryIndex}`}
|
| 145 |
+
penColor={effectivePenColor}
|
| 146 |
+
penSize={penSize}
|
| 147 |
+
isEraserMode={isEraserMode}
|
| 148 |
+
dataURLToLoad={currentDataURL}
|
| 149 |
+
onDrawEnd={handleDrawEnd}
|
| 150 |
+
canvasPhysicalWidth={canvasWidth}
|
| 151 |
+
canvasPhysicalHeight={canvasHeight}
|
| 152 |
+
zoomLevel={zoomLevel}
|
| 153 |
+
/>
|
| 154 |
+
) : (
|
| 155 |
+
<div
|
| 156 |
+
style={{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }}
|
| 157 |
+
className="border-2 border-slate-300 rounded-lg shadow-xl bg-white flex justify-center items-center"
|
| 158 |
+
>
|
| 159 |
+
<p className="text-slate-500">Initializing Canvas...</p>
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
</main>
|
| 164 |
+
|
| 165 |
+
{showAiEditModal && sharedImageUrlForAi && (
|
| 166 |
+
<AiEditModal
|
| 167 |
+
isOpen={showAiEditModal}
|
| 168 |
+
onClose={handleCancelAiEdit}
|
| 169 |
+
onGenerate={handleGenerateAiImage}
|
| 170 |
+
imageUrl={sharedImageUrlForAi}
|
| 171 |
+
prompt={aiPrompt}
|
| 172 |
+
onPromptChange={setAiPrompt}
|
| 173 |
+
isLoading={isGeneratingAiImage}
|
| 174 |
+
error={aiEditError}
|
| 175 |
+
/>
|
| 176 |
+
)}
|
| 177 |
+
|
| 178 |
+
{showZoomSlider && (
|
| 179 |
+
<ZoomSlider
|
| 180 |
+
zoomLevel={zoomLevel}
|
| 181 |
+
onZoomChange={setZoomLevel}
|
| 182 |
+
/>
|
| 183 |
+
)}
|
| 184 |
+
|
| 185 |
+
<SettingsPanel
|
| 186 |
+
isOpen={showSettingsPanel}
|
| 187 |
+
onClose={handleToggleSettingsPanel}
|
| 188 |
+
showZoomSlider={showZoomSlider}
|
| 189 |
+
onShowZoomSliderChange={setShowZoomSlider}
|
| 190 |
+
aiImageQuality={aiImageQuality}
|
| 191 |
+
onAiImageQualityChange={setAiImageQuality}
|
| 192 |
+
aiApiEndpoint={aiApiEndpoint}
|
| 193 |
+
onAiApiEndpointChange={setAiApiEndpoint}
|
| 194 |
+
isFullscreenActive={isFullscreenActive}
|
| 195 |
+
onToggleFullscreen={toggleFullscreen}
|
| 196 |
+
currentCanvasWidth={canvasWidth}
|
| 197 |
+
currentCanvasHeight={canvasHeight}
|
| 198 |
+
onCanvasSizeChange={handleCanvasSizeChange}
|
| 199 |
+
/>
|
| 200 |
+
|
| 201 |
+
<ConfirmModal
|
| 202 |
+
isOpen={confirmModalInfo.isOpen}
|
| 203 |
+
title={confirmModalInfo.title}
|
| 204 |
+
message={confirmModalInfo.message}
|
| 205 |
+
onConfirm={confirmModalInfo.onConfirmAction}
|
| 206 |
+
onCancel={closeConfirmModal}
|
| 207 |
+
isConfirmDestructive={confirmModalInfo.isDestructive}
|
| 208 |
+
confirmText={confirmModalInfo.confirmText}
|
| 209 |
+
cancelText={confirmModalInfo.cancelText}
|
| 210 |
+
/>
|
| 211 |
+
|
| 212 |
+
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center space-y-2 w-full px-4 pointer-events-none">
|
| 213 |
+
{toasts.map(toast => (
|
| 214 |
+
<div
|
| 215 |
+
key={toast.id}
|
| 216 |
+
className={`px-3 py-2 rounded-md shadow-lg text-white text-xs transition-all duration-300 ease-in-out transform
|
| 217 |
+
w-11/12 max-w-md break-words pointer-events-auto
|
| 218 |
+
${toast.type === 'success' ? 'bg-green-600' : ''}
|
| 219 |
+
${toast.type === 'error' ? 'bg-red-600' : ''}
|
| 220 |
+
${toast.type === 'info' ? 'bg-blue-600' : ''}
|
| 221 |
+
opacity-100 translate-y-0`}
|
| 222 |
+
role="alert"
|
| 223 |
+
aria-live="assertive"
|
| 224 |
+
>
|
| 225 |
+
{toast.message}
|
| 226 |
+
</div>
|
| 227 |
+
))}
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<footer className="w-full text-center py-3 text-xs text-slate-500 bg-slate-200 border-t border-slate-300 mt-auto">
|
| 231 |
+
React Paint App © {new Date().getFullYear()}. Artwork autosaved to your browser's IndexedDB.
|
| 232 |
+
</footer>
|
| 233 |
+
</div>
|
| 234 |
+
);
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
export default App;
|
README.md
CHANGED
|
@@ -1,83 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
title: Xpaintdev
|
| 3 |
-
emoji: 🐠
|
| 4 |
-
colorFrom: indigo
|
| 5 |
-
colorTo: red
|
| 6 |
-
sdk: static
|
| 7 |
-
pinned: false
|
| 8 |
-
app_build_command: npm run build
|
| 9 |
-
app_file: dist/index.html
|
| 10 |
-
license: mit
|
| 11 |
-
short_description: cool paint app
|
| 12 |
-
---
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
In the project directory, you can run:
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
The page will reload when you make changes.\
|
| 28 |
-
You may also see any lint errors in the console.
|
| 29 |
-
|
| 30 |
-
### `npm test`
|
| 31 |
-
|
| 32 |
-
Launches the test runner in the interactive watch mode.\
|
| 33 |
-
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
| 34 |
-
|
| 35 |
-
### `npm run build`
|
| 36 |
-
|
| 37 |
-
Builds the app for production to the `build` folder.\
|
| 38 |
-
It correctly bundles React in production mode and optimizes the build for the best performance.
|
| 39 |
-
|
| 40 |
-
The build is minified and the filenames include the hashes.\
|
| 41 |
-
Your app is ready to be deployed!
|
| 42 |
-
|
| 43 |
-
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
| 44 |
-
|
| 45 |
-
### `npm run eject`
|
| 46 |
-
|
| 47 |
-
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
| 48 |
-
|
| 49 |
-
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
| 50 |
-
|
| 51 |
-
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
| 52 |
-
|
| 53 |
-
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
| 54 |
-
|
| 55 |
-
## Learn More
|
| 56 |
-
|
| 57 |
-
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
| 58 |
-
|
| 59 |
-
To learn React, check out the [React documentation](https://reactjs.org/).
|
| 60 |
-
|
| 61 |
-
### Code Splitting
|
| 62 |
-
|
| 63 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
| 64 |
-
|
| 65 |
-
### Analyzing the Bundle Size
|
| 66 |
-
|
| 67 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
| 68 |
-
|
| 69 |
-
### Making a Progressive Web App
|
| 70 |
-
|
| 71 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
| 72 |
-
|
| 73 |
-
### Advanced Configuration
|
| 74 |
-
|
| 75 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
| 76 |
-
|
| 77 |
-
### Deployment
|
| 78 |
-
|
| 79 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
| 80 |
-
|
| 81 |
-
### `npm run build` fails to minify
|
| 82 |
-
|
| 83 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
|
|
|
| 1 |
+
# Run and deploy your AI Studio app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
This contains everything you need to run your app locally.
|
| 4 |
|
| 5 |
+
## Run Locally
|
| 6 |
|
| 7 |
+
**Prerequisites:** Node.js
|
| 8 |
|
|
|
|
| 9 |
|
| 10 |
+
1. Install dependencies:
|
| 11 |
+
`npm install`
|
| 12 |
+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
| 13 |
+
3. Run the app:
|
| 14 |
+
`npm run dev`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apidoc/fileworker.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Global rate limiting map and constants
|
| 2 |
+
const requestCounter = new Map();
|
| 3 |
+
const RATE_LIMIT = 20; // requests per minute
|
| 4 |
+
const WINDOW_SIZE = 60 * 1000; // 1 minute in milliseconds
|
| 5 |
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 20MB in bytes
|
| 6 |
+
const MAX_STORAGE = 100 * 1024 * 1024 * 1024; // 100GB in bytes
|
| 7 |
+
|
| 8 |
+
async function getStorageUsage(bucket) {
|
| 9 |
+
let totalSize = 0;
|
| 10 |
+
let cursor;
|
| 11 |
+
|
| 12 |
+
do {
|
| 13 |
+
const listing = await bucket.list({
|
| 14 |
+
cursor,
|
| 15 |
+
include: ['customMetadata', 'httpMetadata'],
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
// Sum up sizes of all objects in current page
|
| 19 |
+
for (const object of listing.objects) {
|
| 20 |
+
totalSize += object.size;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
cursor = listing.cursor;
|
| 24 |
+
} while (cursor); // Continue until no more pages
|
| 25 |
+
|
| 26 |
+
return totalSize;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function objectNotFound(objectName) {
|
| 30 |
+
return new Response(`<html><body>R2 object "<b>${objectName}</b>" not found</body></html>`, {
|
| 31 |
+
status: 404,
|
| 32 |
+
headers: {
|
| 33 |
+
'content-type': 'text/html; charset=UTF-8',
|
| 34 |
+
'Access-Control-Allow-Origin': '*',
|
| 35 |
+
'Access-Control-Allow-Methods': 'GET, HEAD, PUT, POST, OPTIONS',
|
| 36 |
+
'Access-Control-Allow-Headers': '*'
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function corsHeaders(headers = new Headers()) {
|
| 42 |
+
headers.set('Access-Control-Allow-Origin', '*');
|
| 43 |
+
headers.set('Access-Control-Allow-Methods', 'GET, HEAD, PUT, POST, OPTIONS');
|
| 44 |
+
headers.set('Access-Control-Allow-Headers', '*');
|
| 45 |
+
return headers;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function checkRateLimit(ip) {
|
| 49 |
+
const now = Date.now();
|
| 50 |
+
const userRequests = requestCounter.get(ip) || [];
|
| 51 |
+
|
| 52 |
+
// Remove requests older than the window size
|
| 53 |
+
const validRequests = userRequests.filter(timestamp => now - timestamp < WINDOW_SIZE);
|
| 54 |
+
|
| 55 |
+
if (validRequests.length >= RATE_LIMIT) {
|
| 56 |
+
return false;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Add current request timestamp
|
| 60 |
+
validRequests.push(now);
|
| 61 |
+
requestCounter.set(ip, validRequests);
|
| 62 |
+
|
| 63 |
+
// Cleanup old entries periodically
|
| 64 |
+
if (Math.random() < 0.1) {
|
| 65 |
+
for (const [key, timestamps] of requestCounter.entries()) {
|
| 66 |
+
const validTimestamps = timestamps.filter(ts => now - ts < WINDOW_SIZE);
|
| 67 |
+
if (validTimestamps.length === 0) {
|
| 68 |
+
requestCounter.delete(key);
|
| 69 |
+
} else {
|
| 70 |
+
requestCounter.set(key, validTimestamps);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return true;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export default {
|
| 79 |
+
async fetch(request, env) {
|
| 80 |
+
// Get client IP
|
| 81 |
+
const clientIP = request.headers.get('cf-connecting-ip') || 'unknown';
|
| 82 |
+
|
| 83 |
+
// Check rate limit
|
| 84 |
+
if (!checkRateLimit(clientIP)) {
|
| 85 |
+
return new Response('Rate limit exceeded. Please try again later.', {
|
| 86 |
+
status: 429,
|
| 87 |
+
headers: corsHeaders(new Headers({
|
| 88 |
+
'Retry-After': '60'
|
| 89 |
+
}))
|
| 90 |
+
});
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const url = new URL(request.url);
|
| 94 |
+
const objectName = url.pathname.slice(1);
|
| 95 |
+
console.log(`${request.method} object ${objectName}: ${request.url}`);
|
| 96 |
+
|
| 97 |
+
// Get current storage usage
|
| 98 |
+
const currentUsage = await getStorageUsage(env.MY_BUCKET);
|
| 99 |
+
console.log(`Current R2 storage usage: ${(currentUsage / (1024 * 1024 * 1024)).toFixed(2)} GB`);
|
| 100 |
+
|
| 101 |
+
// Handle CORS preflight
|
| 102 |
+
if (request.method === 'OPTIONS') {
|
| 103 |
+
return new Response(null, {
|
| 104 |
+
headers: corsHeaders()
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
| 109 |
+
if (objectName === '') {
|
| 110 |
+
if (request.method === 'HEAD') {
|
| 111 |
+
return new Response(undefined, {
|
| 112 |
+
status: 400,
|
| 113 |
+
headers: corsHeaders()
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
const options = {
|
| 117 |
+
prefix: url.searchParams.get('prefix') ?? undefined,
|
| 118 |
+
delimiter: url.searchParams.get('delimiter') ?? undefined,
|
| 119 |
+
cursor: url.searchParams.get('cursor') ?? undefined,
|
| 120 |
+
include: ['customMetadata', 'httpMetadata'],
|
| 121 |
+
};
|
| 122 |
+
console.log(JSON.stringify(options));
|
| 123 |
+
const listing = await env.MY_BUCKET.list(options);
|
| 124 |
+
return new Response('specify your path,current total storage used:' +currentUsage/1000/1000/1000+' GB', {
|
| 125 |
+
headers: corsHeaders(new Headers({
|
| 126 |
+
'content-type': 'application/json; charset=UTF-8'
|
| 127 |
+
}))
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if (request.method === 'GET') {
|
| 132 |
+
const object = await env.MY_BUCKET.get(objectName, {
|
| 133 |
+
range: request.headers,
|
| 134 |
+
onlyIf: request.headers,
|
| 135 |
+
});
|
| 136 |
+
if (object === null) {
|
| 137 |
+
return objectNotFound(objectName);
|
| 138 |
+
}
|
| 139 |
+
const headers = new Headers();
|
| 140 |
+
object.writeHttpMetadata(headers);
|
| 141 |
+
headers.set('etag', object.httpEtag);
|
| 142 |
+
if (object.range) {
|
| 143 |
+
headers.set("content-range", `bytes ${object.range.offset}-${object.range.end ?? object.size - 1}/${object.size}`);
|
| 144 |
+
}
|
| 145 |
+
const status = object.body ? (request.headers.get("range") !== null ? 206 : 200) : 304;
|
| 146 |
+
return new Response(object.body, {
|
| 147 |
+
headers: corsHeaders(headers),
|
| 148 |
+
status
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const object = await env.MY_BUCKET.head(objectName);
|
| 153 |
+
if (object === null) {
|
| 154 |
+
return objectNotFound(objectName);
|
| 155 |
+
}
|
| 156 |
+
const headers = new Headers();
|
| 157 |
+
object.writeHttpMetadata(headers);
|
| 158 |
+
headers.set('etag', object.httpEtag);
|
| 159 |
+
return new Response(null, {
|
| 160 |
+
headers: corsHeaders(headers)
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
if (request.method === 'PUT' || request.method === 'POST') {
|
| 165 |
+
// Check if storage usage exceeds limit
|
| 166 |
+
if (currentUsage >= MAX_STORAGE) {
|
| 167 |
+
return new Response('Storage limit exceeded (100GB). Delete some files before uploading.', {
|
| 168 |
+
status: 507, // Insufficient Storage
|
| 169 |
+
headers: corsHeaders()
|
| 170 |
+
});
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Check content length
|
| 174 |
+
const contentLength = parseInt(request.headers.get('content-length') || '0');
|
| 175 |
+
if (contentLength > MAX_FILE_SIZE) {
|
| 176 |
+
return new Response('File too large. Maximum size is 20MB.', {
|
| 177 |
+
status: 413,
|
| 178 |
+
headers: corsHeaders()
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Handle chunked uploads
|
| 183 |
+
if (!contentLength) {
|
| 184 |
+
const chunks = [];
|
| 185 |
+
let totalSize = 0;
|
| 186 |
+
for await (const chunk of request.body) {
|
| 187 |
+
totalSize += chunk.length;
|
| 188 |
+
if (totalSize > MAX_FILE_SIZE) {
|
| 189 |
+
return new Response('File too large. Maximum size is 20MB.', {
|
| 190 |
+
status: 413,
|
| 191 |
+
headers: corsHeaders()
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
chunks.push(chunk);
|
| 195 |
+
}
|
| 196 |
+
request.body = new Blob(chunks);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// Check if this upload would exceed storage limit
|
| 200 |
+
if (currentUsage + contentLength >= MAX_STORAGE) {
|
| 201 |
+
return new Response('This upload would exceed the 100GB storage limit.', {
|
| 202 |
+
status: 507,
|
| 203 |
+
headers: corsHeaders()
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const object = await env.MY_BUCKET.put(objectName, request.body, {
|
| 208 |
+
httpMetadata: request.headers,
|
| 209 |
+
});
|
| 210 |
+
return new Response(objectName, {
|
| 211 |
+
headers: corsHeaders(new Headers({
|
| 212 |
+
'etag': object.httpEtag,
|
| 213 |
+
'X-Storage-Usage': `${(currentUsage / (1024 * 1024 * 1024)).toFixed(2)}GB`
|
| 214 |
+
}))
|
| 215 |
+
});
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
if (request.method === 'DELETE') {
|
| 219 |
+
return new Response('delete unsupported', {
|
| 220 |
+
headers: corsHeaders()
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return new Response(`Unsupported method`, {
|
| 225 |
+
status: 400,
|
| 226 |
+
headers: corsHeaders()
|
| 227 |
+
});
|
| 228 |
+
}
|
| 229 |
+
}
|
components/AiEditModal.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { MagicSparkleIcon, CloseIcon } from './icons';
|
| 3 |
+
|
| 4 |
+
interface AiEditModalProps {
|
| 5 |
+
isOpen: boolean;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
onGenerate: () => void;
|
| 8 |
+
imageUrl: string;
|
| 9 |
+
prompt: string;
|
| 10 |
+
onPromptChange: (newPrompt: string) => void;
|
| 11 |
+
isLoading: boolean;
|
| 12 |
+
error: string | null;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const AiEditModal: React.FC<AiEditModalProps> = ({
|
| 16 |
+
isOpen,
|
| 17 |
+
onClose,
|
| 18 |
+
onGenerate,
|
| 19 |
+
imageUrl,
|
| 20 |
+
prompt,
|
| 21 |
+
onPromptChange,
|
| 22 |
+
isLoading,
|
| 23 |
+
error,
|
| 24 |
+
}) => {
|
| 25 |
+
if (!isOpen) return null;
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
className="fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm flex justify-center items-center z-[1000] p-4"
|
| 30 |
+
aria-modal="true"
|
| 31 |
+
role="dialog"
|
| 32 |
+
aria-labelledby="ai-edit-modal-title"
|
| 33 |
+
>
|
| 34 |
+
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-lg transform transition-all">
|
| 35 |
+
<div className="flex justify-between items-center mb-4">
|
| 36 |
+
<h2 id="ai-edit-modal-title" className="text-xl font-semibold text-slate-700">Edit Image with AI</h2>
|
| 37 |
+
<button
|
| 38 |
+
onClick={onClose}
|
| 39 |
+
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 40 |
+
aria-label="Close AI edit modal"
|
| 41 |
+
disabled={isLoading}
|
| 42 |
+
>
|
| 43 |
+
<CloseIcon className="w-6 h-6" />
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="mb-4">
|
| 48 |
+
<p className="text-sm text-slate-600 mb-1">Your uploaded image:</p>
|
| 49 |
+
<img
|
| 50 |
+
src={imageUrl}
|
| 51 |
+
alt="Uploaded image preview"
|
| 52 |
+
className="max-w-full h-auto max-h-48 rounded border border-slate-300 object-contain mx-auto"
|
| 53 |
+
/>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div className="mb-4">
|
| 57 |
+
<label htmlFor="aiPrompt" className="block text-sm font-medium text-slate-700 mb-1">
|
| 58 |
+
Describe what you want to change:
|
| 59 |
+
</label>
|
| 60 |
+
<textarea
|
| 61 |
+
id="aiPrompt"
|
| 62 |
+
value={prompt}
|
| 63 |
+
onChange={(e) => onPromptChange(e.target.value)}
|
| 64 |
+
placeholder="e.g., 'make the cat wear a party hat', 'change background to a beach'"
|
| 65 |
+
rows={3}
|
| 66 |
+
className="w-full p-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-shadow text-sm"
|
| 67 |
+
disabled={isLoading}
|
| 68 |
+
aria-describedby={error ? "ai-edit-error" : undefined}
|
| 69 |
+
/>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
{error && (
|
| 73 |
+
<div id="ai-edit-error" className="mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-md text-sm" role="alert">
|
| 74 |
+
<p className="font-semibold">Error:</p>
|
| 75 |
+
<p>{error}</p>
|
| 76 |
+
</div>
|
| 77 |
+
)}
|
| 78 |
+
|
| 79 |
+
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
| 80 |
+
<button
|
| 81 |
+
onClick={onClose}
|
| 82 |
+
disabled={isLoading}
|
| 83 |
+
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-md hover:bg-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
| 84 |
+
>
|
| 85 |
+
Cancel
|
| 86 |
+
</button>
|
| 87 |
+
<button
|
| 88 |
+
onClick={onGenerate}
|
| 89 |
+
disabled={isLoading || !prompt.trim()}
|
| 90 |
+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium flex items-center justify-center gap-2"
|
| 91 |
+
>
|
| 92 |
+
{isLoading ? (
|
| 93 |
+
<>
|
| 94 |
+
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 95 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 96 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 97 |
+
</svg>
|
| 98 |
+
Generating...
|
| 99 |
+
</>
|
| 100 |
+
) : (
|
| 101 |
+
<>
|
| 102 |
+
<MagicSparkleIcon className="w-4 h-4 mr-1" />
|
| 103 |
+
Generate Image
|
| 104 |
+
</>
|
| 105 |
+
)}
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
export default AiEditModal;
|
components/CanvasComponent.tsx
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
interface CanvasComponentProps {
|
| 4 |
+
penColor: string;
|
| 5 |
+
penSize: number;
|
| 6 |
+
isEraserMode: boolean;
|
| 7 |
+
dataURLToLoad?: string | null;
|
| 8 |
+
onDrawEnd: (dataURL: string) => void;
|
| 9 |
+
canvasPhysicalWidth: number;
|
| 10 |
+
canvasPhysicalHeight: number;
|
| 11 |
+
zoomLevel: number; // New prop
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const CanvasComponent: React.FC<CanvasComponentProps> = ({
|
| 15 |
+
penColor,
|
| 16 |
+
penSize,
|
| 17 |
+
isEraserMode,
|
| 18 |
+
dataURLToLoad,
|
| 19 |
+
onDrawEnd,
|
| 20 |
+
canvasPhysicalWidth,
|
| 21 |
+
canvasPhysicalHeight,
|
| 22 |
+
zoomLevel, // Use zoomLevel
|
| 23 |
+
}) => {
|
| 24 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 25 |
+
const [isDrawing, setIsDrawing] = useState(false);
|
| 26 |
+
const lastPositionRef = useRef<{ x: number; y: number } | null>(null);
|
| 27 |
+
|
| 28 |
+
const getCanvasContext = useCallback((): CanvasRenderingContext2D | null => {
|
| 29 |
+
const canvas = canvasRef.current;
|
| 30 |
+
return canvas ? canvas.getContext('2d') : null;
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
const canvas = canvasRef.current;
|
| 35 |
+
const context = getCanvasContext();
|
| 36 |
+
if (canvas && context) {
|
| 37 |
+
if (dataURLToLoad) {
|
| 38 |
+
const img = new Image();
|
| 39 |
+
img.onload = () => {
|
| 40 |
+
context.fillStyle = '#FFFFFF';
|
| 41 |
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
| 42 |
+
context.drawImage(img, 0, 0);
|
| 43 |
+
};
|
| 44 |
+
img.onerror = () => {
|
| 45 |
+
console.error("Failed to load image from dataURL for canvas. Displaying a blank canvas.");
|
| 46 |
+
context.fillStyle = '#FFFFFF';
|
| 47 |
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
| 48 |
+
}
|
| 49 |
+
img.src = dataURLToLoad;
|
| 50 |
+
} else {
|
| 51 |
+
context.fillStyle = '#FFFFFF';
|
| 52 |
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}, [dataURLToLoad, getCanvasContext, canvasPhysicalWidth, canvasPhysicalHeight]);
|
| 56 |
+
|
| 57 |
+
const getRelativePosition = useCallback((event: MouseEvent | TouchEvent): { x: number; y: number } | null => {
|
| 58 |
+
const canvas = canvasRef.current;
|
| 59 |
+
if (!canvas) return null;
|
| 60 |
+
|
| 61 |
+
const rect = canvas.getBoundingClientRect(); // This rect is of the visually scaled canvas
|
| 62 |
+
let clientX, clientY;
|
| 63 |
+
|
| 64 |
+
if (event instanceof TouchEvent) {
|
| 65 |
+
if (event.touches.length === 0) return null;
|
| 66 |
+
clientX = event.touches[0].clientX;
|
| 67 |
+
clientY = event.touches[0].clientY;
|
| 68 |
+
} else {
|
| 69 |
+
clientX = event.clientX;
|
| 70 |
+
clientY = event.clientY;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Adjust coordinates based on zoom level
|
| 74 |
+
// (clientX - rect.left) gives position relative to the scaled canvas's top-left on screen
|
| 75 |
+
// Divide by zoomLevel to get position relative to the unscaled canvas's internal coordinates
|
| 76 |
+
return {
|
| 77 |
+
x: (clientX - rect.left) / zoomLevel,
|
| 78 |
+
y: (clientY - rect.top) / zoomLevel,
|
| 79 |
+
};
|
| 80 |
+
}, [zoomLevel]); // Add zoomLevel to dependencies
|
| 81 |
+
|
| 82 |
+
const startDrawing = useCallback((event: MouseEvent | TouchEvent) => {
|
| 83 |
+
event.preventDefault();
|
| 84 |
+
const pos = getRelativePosition(event);
|
| 85 |
+
if (!pos) return;
|
| 86 |
+
|
| 87 |
+
const context = getCanvasContext();
|
| 88 |
+
if(!context) return;
|
| 89 |
+
|
| 90 |
+
setIsDrawing(true);
|
| 91 |
+
lastPositionRef.current = pos;
|
| 92 |
+
|
| 93 |
+
context.lineCap = 'round';
|
| 94 |
+
context.lineJoin = 'round';
|
| 95 |
+
|
| 96 |
+
if (isEraserMode) {
|
| 97 |
+
context.globalCompositeOperation = 'destination-out';
|
| 98 |
+
context.strokeStyle = "rgba(0,0,0,1)";
|
| 99 |
+
context.fillStyle = "rgba(0,0,0,1)";
|
| 100 |
+
} else {
|
| 101 |
+
context.globalCompositeOperation = 'source-over';
|
| 102 |
+
context.strokeStyle = penColor;
|
| 103 |
+
context.fillStyle = penColor;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
context.beginPath();
|
| 107 |
+
// For single click dots, use arc. For lines, it's just the start point.
|
| 108 |
+
// The actual drawing of the dot happens here.
|
| 109 |
+
context.arc(pos.x, pos.y, penSize / (2 * zoomLevel), 0, Math.PI * 2); // Scale dot size with zoom for visual consistency
|
| 110 |
+
context.lineWidth = penSize / zoomLevel; // Scale line width for visual consistency
|
| 111 |
+
context.fill();
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
}, [getCanvasContext, penColor, penSize, isEraserMode, getRelativePosition, zoomLevel]);
|
| 115 |
+
|
| 116 |
+
const draw = useCallback((event: MouseEvent | TouchEvent) => {
|
| 117 |
+
if (!isDrawing) return;
|
| 118 |
+
event.preventDefault();
|
| 119 |
+
const pos = getRelativePosition(event);
|
| 120 |
+
if (!pos || !lastPositionRef.current) return;
|
| 121 |
+
|
| 122 |
+
const context = getCanvasContext();
|
| 123 |
+
if (context) {
|
| 124 |
+
context.lineWidth = penSize / zoomLevel; // Scale line width
|
| 125 |
+
context.lineCap = 'round';
|
| 126 |
+
context.lineJoin = 'round';
|
| 127 |
+
|
| 128 |
+
if (isEraserMode) {
|
| 129 |
+
context.globalCompositeOperation = 'destination-out';
|
| 130 |
+
context.strokeStyle = "rgba(0,0,0,1)";
|
| 131 |
+
} else {
|
| 132 |
+
context.globalCompositeOperation = 'source-over';
|
| 133 |
+
context.strokeStyle = penColor;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
context.beginPath();
|
| 137 |
+
context.moveTo(lastPositionRef.current.x, lastPositionRef.current.y);
|
| 138 |
+
context.lineTo(pos.x, pos.y);
|
| 139 |
+
context.stroke();
|
| 140 |
+
lastPositionRef.current = pos;
|
| 141 |
+
}
|
| 142 |
+
}, [isDrawing, getCanvasContext, penColor, penSize, isEraserMode, getRelativePosition, zoomLevel]);
|
| 143 |
+
|
| 144 |
+
const endDrawing = useCallback(() => {
|
| 145 |
+
if (!isDrawing) return;
|
| 146 |
+
setIsDrawing(false);
|
| 147 |
+
|
| 148 |
+
const canvas = canvasRef.current;
|
| 149 |
+
if (canvas) {
|
| 150 |
+
const context = getCanvasContext();
|
| 151 |
+
if (context) {
|
| 152 |
+
context.globalCompositeOperation = 'source-over'; // Reset composite operation
|
| 153 |
+
}
|
| 154 |
+
onDrawEnd(canvas.toDataURL('image/png'));
|
| 155 |
+
}
|
| 156 |
+
lastPositionRef.current = null;
|
| 157 |
+
}, [isDrawing, onDrawEnd, getCanvasContext]);
|
| 158 |
+
|
| 159 |
+
useEffect(() => {
|
| 160 |
+
const canvas = canvasRef.current;
|
| 161 |
+
if (!canvas) return;
|
| 162 |
+
|
| 163 |
+
// Passive false is important for preventDefault() to work on touch events and prevent scrolling page.
|
| 164 |
+
const eventOptions = { passive: false };
|
| 165 |
+
|
| 166 |
+
canvas.addEventListener('mousedown', startDrawing, eventOptions);
|
| 167 |
+
canvas.addEventListener('mousemove', draw, eventOptions);
|
| 168 |
+
canvas.addEventListener('mouseup', endDrawing, eventOptions);
|
| 169 |
+
canvas.addEventListener('mouseleave', endDrawing, eventOptions);
|
| 170 |
+
|
| 171 |
+
canvas.addEventListener('touchstart', startDrawing, eventOptions);
|
| 172 |
+
canvas.addEventListener('touchmove', draw, eventOptions);
|
| 173 |
+
canvas.addEventListener('touchend', endDrawing, eventOptions);
|
| 174 |
+
canvas.addEventListener('touchcancel', endDrawing, eventOptions);
|
| 175 |
+
|
| 176 |
+
return () => {
|
| 177 |
+
canvas.removeEventListener('mousedown', startDrawing);
|
| 178 |
+
canvas.removeEventListener('mousemove', draw);
|
| 179 |
+
canvas.removeEventListener('mouseup', endDrawing);
|
| 180 |
+
canvas.removeEventListener('mouseleave', endDrawing);
|
| 181 |
+
canvas.removeEventListener('touchstart', startDrawing);
|
| 182 |
+
canvas.removeEventListener('touchmove', draw);
|
| 183 |
+
canvas.removeEventListener('touchend', endDrawing);
|
| 184 |
+
canvas.removeEventListener('touchcancel', endDrawing);
|
| 185 |
+
};
|
| 186 |
+
}, [startDrawing, draw, endDrawing]); // Re-bind if these handlers change (e.g. due to zoomLevel)
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<canvas
|
| 190 |
+
ref={canvasRef}
|
| 191 |
+
width={canvasPhysicalWidth}
|
| 192 |
+
height={canvasPhysicalHeight}
|
| 193 |
+
className="border-2 border-slate-400 rounded-lg shadow-2xl touch-none bg-white cursor-crosshair"
|
| 194 |
+
// Style for imageRendering is good for pixel art, ensure it doesn't conflict with scaling needs.
|
| 195 |
+
// Visual scaling is handled by the parent div's transform.
|
| 196 |
+
style={{ imageRendering: 'pixelated' }}
|
| 197 |
+
aria-label="Drawing canvas"
|
| 198 |
+
/>
|
| 199 |
+
);
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
export default CanvasComponent;
|
components/ConfirmModal.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { CloseIcon } from './icons'; // Assuming CloseIcon can be used for a general "cancel" or is styled appropriately
|
| 3 |
+
|
| 4 |
+
interface ConfirmModalProps {
|
| 5 |
+
isOpen: boolean;
|
| 6 |
+
title: string;
|
| 7 |
+
message: string | React.ReactNode;
|
| 8 |
+
confirmText?: string;
|
| 9 |
+
cancelText?: string;
|
| 10 |
+
onConfirm: () => void;
|
| 11 |
+
onCancel: () => void;
|
| 12 |
+
isConfirmDestructive?: boolean; // Optional: to style confirm button differently for destructive actions
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
| 16 |
+
isOpen,
|
| 17 |
+
title,
|
| 18 |
+
message,
|
| 19 |
+
confirmText = 'Confirm',
|
| 20 |
+
cancelText = 'Cancel',
|
| 21 |
+
onConfirm,
|
| 22 |
+
onCancel,
|
| 23 |
+
isConfirmDestructive = false,
|
| 24 |
+
}) => {
|
| 25 |
+
if (!isOpen) return null;
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
className="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex justify-center items-center z-[1100] p-4"
|
| 30 |
+
aria-modal="true"
|
| 31 |
+
role="dialog"
|
| 32 |
+
aria-labelledby="confirm-modal-title"
|
| 33 |
+
>
|
| 34 |
+
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-md transform transition-all">
|
| 35 |
+
<div className="flex justify-between items-center mb-4">
|
| 36 |
+
<h2 id="confirm-modal-title" className="text-lg font-semibold text-slate-800">
|
| 37 |
+
{title}
|
| 38 |
+
</h2>
|
| 39 |
+
<button
|
| 40 |
+
onClick={onCancel}
|
| 41 |
+
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 42 |
+
aria-label="Close confirmation dialog"
|
| 43 |
+
>
|
| 44 |
+
<CloseIcon className="w-5 h-5" />
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div className="mb-6 text-sm text-slate-600">
|
| 49 |
+
{typeof message === 'string' ? <p>{message}</p> : message}
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
| 53 |
+
<button
|
| 54 |
+
onClick={onCancel}
|
| 55 |
+
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-md hover:bg-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium w-full sm:w-auto"
|
| 56 |
+
>
|
| 57 |
+
{cancelText}
|
| 58 |
+
</button>
|
| 59 |
+
<button
|
| 60 |
+
onClick={onConfirm}
|
| 61 |
+
className={`px-4 py-2 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium w-full sm:w-auto
|
| 62 |
+
${isConfirmDestructive ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
| 63 |
+
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'}
|
| 64 |
+
focus:ring-2 focus:ring-offset-2`}
|
| 65 |
+
>
|
| 66 |
+
{confirmText}
|
| 67 |
+
</button>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
export default ConfirmModal;
|
components/SettingsPanel.tsx
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { CloseIcon, FullscreenEnterIcon, FullscreenExitIcon } from './icons'; // Import fullscreen icons
|
| 3 |
+
import { AiImageQuality } from '../hooks/useAiFeatures';
|
| 4 |
+
|
| 5 |
+
interface SettingsPanelProps {
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
showZoomSlider: boolean;
|
| 9 |
+
onShowZoomSliderChange: (show: boolean) => void;
|
| 10 |
+
aiImageQuality: AiImageQuality;
|
| 11 |
+
onAiImageQualityChange: (quality: AiImageQuality) => void;
|
| 12 |
+
aiApiEndpoint: string;
|
| 13 |
+
onAiApiEndpointChange: (endpoint: string) => void;
|
| 14 |
+
isFullscreenActive: boolean;
|
| 15 |
+
onToggleFullscreen: () => void;
|
| 16 |
+
currentCanvasWidth: number;
|
| 17 |
+
currentCanvasHeight: number;
|
| 18 |
+
onCanvasSizeChange: (width: number, height: number) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const CANVAS_SIZE_OPTIONS = [
|
| 22 |
+
{ label: '1000 x 1000 px', width: 1000, height: 1000 },
|
| 23 |
+
{ label: '1200 x 1200 px', width: 1200, height: 1200 },
|
| 24 |
+
{ label: '1600 x 1600 px', width: 1600, height: 1600 },
|
| 25 |
+
{ label: '2000 x 2000 px', width: 2000, height: 2000 },
|
| 26 |
+
{ label: '4000 x 4000 px', width: 4000, height: 4000 },
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
| 30 |
+
isOpen,
|
| 31 |
+
onClose,
|
| 32 |
+
showZoomSlider,
|
| 33 |
+
onShowZoomSliderChange,
|
| 34 |
+
aiImageQuality,
|
| 35 |
+
onAiImageQualityChange,
|
| 36 |
+
aiApiEndpoint,
|
| 37 |
+
onAiApiEndpointChange,
|
| 38 |
+
isFullscreenActive,
|
| 39 |
+
onToggleFullscreen,
|
| 40 |
+
currentCanvasWidth,
|
| 41 |
+
currentCanvasHeight,
|
| 42 |
+
onCanvasSizeChange,
|
| 43 |
+
}) => {
|
| 44 |
+
if (!isOpen) return null;
|
| 45 |
+
|
| 46 |
+
const qualityOptions: { label: string; value: AiImageQuality }[] = [
|
| 47 |
+
{ label: 'Low', value: 'low' },
|
| 48 |
+
{ label: 'Medium', value: 'medium' },
|
| 49 |
+
{ label: 'High (HD)', value: 'hd' },
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
const handleSizeSelection = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
| 53 |
+
const selectedValue = event.target.value;
|
| 54 |
+
const [newWidth, newHeight] = selectedValue.split('x').map(Number);
|
| 55 |
+
if (!isNaN(newWidth) && !isNaN(newHeight)) {
|
| 56 |
+
onCanvasSizeChange(newWidth, newHeight);
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const currentSizeValue = `${currentCanvasWidth}x${currentCanvasHeight}`;
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div
|
| 64 |
+
className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex justify-center items-center z-[1000] p-4"
|
| 65 |
+
aria-modal="true"
|
| 66 |
+
role="dialog"
|
| 67 |
+
aria-labelledby="settings-panel-title"
|
| 68 |
+
>
|
| 69 |
+
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-md transform transition-all overflow-y-auto max-h-[90vh]">
|
| 70 |
+
<div className="flex justify-between items-center mb-6">
|
| 71 |
+
<h2 id="settings-panel-title" className="text-xl font-semibold text-slate-800">
|
| 72 |
+
Application Settings
|
| 73 |
+
</h2>
|
| 74 |
+
<button
|
| 75 |
+
onClick={onClose}
|
| 76 |
+
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 77 |
+
aria-label="Close settings panel"
|
| 78 |
+
>
|
| 79 |
+
<CloseIcon className="w-6 h-6" />
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
{/* Interface Settings */}
|
| 84 |
+
<div className="mb-6">
|
| 85 |
+
<h3 className="text-md font-medium text-slate-700 mb-2">Interface</h3>
|
| 86 |
+
<div className="space-y-3">
|
| 87 |
+
{/* Zoom Slider Toggle */}
|
| 88 |
+
<div className="flex items-center justify-between bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 89 |
+
<label htmlFor="showZoomSliderToggle" className="text-sm text-slate-600">
|
| 90 |
+
Show Zoom Slider
|
| 91 |
+
</label>
|
| 92 |
+
<button
|
| 93 |
+
id="showZoomSliderToggle"
|
| 94 |
+
role="switch"
|
| 95 |
+
aria-checked={showZoomSlider}
|
| 96 |
+
onClick={() => onShowZoomSliderChange(!showZoomSlider)}
|
| 97 |
+
className={`relative inline-flex items-center h-6 rounded-full w-11 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
| 98 |
+
showZoomSlider ? 'bg-blue-600' : 'bg-slate-300'
|
| 99 |
+
}`}
|
| 100 |
+
>
|
| 101 |
+
<span className="sr-only">Toggle Zoom Slider Visibility</span>
|
| 102 |
+
<span
|
| 103 |
+
className={`inline-block w-4 h-4 transform bg-white rounded-full transition-transform duration-200 ease-in-out ${
|
| 104 |
+
showZoomSlider ? 'translate-x-6' : 'translate-x-1'
|
| 105 |
+
}`}
|
| 106 |
+
/>
|
| 107 |
+
</button>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{/* Fullscreen Toggle */}
|
| 111 |
+
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 112 |
+
<button
|
| 113 |
+
onClick={onToggleFullscreen}
|
| 114 |
+
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
| 115 |
+
aria-label={isFullscreenActive ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
| 116 |
+
>
|
| 117 |
+
{isFullscreenActive ? (
|
| 118 |
+
<FullscreenExitIcon className="w-5 h-5 mr-2" />
|
| 119 |
+
) : (
|
| 120 |
+
<FullscreenEnterIcon className="w-5 h-5 mr-2" />
|
| 121 |
+
)}
|
| 122 |
+
{isFullscreenActive ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Canvas Dimensions Settings */}
|
| 129 |
+
<div className="mb-6">
|
| 130 |
+
<h3 className="text-md font-medium text-slate-700 mb-2">Canvas Dimensions</h3>
|
| 131 |
+
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 132 |
+
<label htmlFor="canvasSizeSelect" className="block text-sm text-slate-600 mb-1">
|
| 133 |
+
Canvas Size
|
| 134 |
+
</label>
|
| 135 |
+
<select
|
| 136 |
+
id="canvasSizeSelect"
|
| 137 |
+
value={currentSizeValue}
|
| 138 |
+
onChange={handleSizeSelection}
|
| 139 |
+
className="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-shadow text-sm"
|
| 140 |
+
aria-describedby="canvas-size-description"
|
| 141 |
+
>
|
| 142 |
+
{CANVAS_SIZE_OPTIONS.map((option) => (
|
| 143 |
+
<option key={`${option.width}x${option.height}`} value={`${option.width}x${option.height}`}>
|
| 144 |
+
{option.label}
|
| 145 |
+
</option>
|
| 146 |
+
))}
|
| 147 |
+
</select>
|
| 148 |
+
<p id="canvas-size-description" className="text-xs text-slate-500 mt-1">
|
| 149 |
+
Changing size will clear the current canvas and history.
|
| 150 |
+
</p>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
{/* AI Image Generation Settings */}
|
| 156 |
+
<div className="mb-6">
|
| 157 |
+
<h3 className="text-md font-medium text-slate-700 mb-2">AI Image Generation</h3>
|
| 158 |
+
|
| 159 |
+
<div className="bg-slate-50 p-3 rounded-md border border-slate-200 mb-4">
|
| 160 |
+
<label htmlFor="aiApiEndpointInput" className="block text-sm text-slate-600 mb-1">
|
| 161 |
+
API Endpoint URL
|
| 162 |
+
</label>
|
| 163 |
+
<input
|
| 164 |
+
type="text"
|
| 165 |
+
id="aiApiEndpointInput"
|
| 166 |
+
value={aiApiEndpoint}
|
| 167 |
+
onChange={(e) => onAiApiEndpointChange(e.target.value)}
|
| 168 |
+
className="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-shadow text-sm"
|
| 169 |
+
placeholder="Enter AI API URL"
|
| 170 |
+
aria-describedby="ai-api-description"
|
| 171 |
+
/>
|
| 172 |
+
<p id="ai-api-description" className="text-xs text-slate-500 mt-1">
|
| 173 |
+
Use <code className="bg-slate-200 px-1 rounded">{'{prompt}'}</code> for text prompt and <code className="bg-slate-200 px-1 rounded">{'{imgurl.url}'}</code> for image URL.
|
| 174 |
+
</p>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 178 |
+
<label htmlFor="aiImageQualitySelect" className="block text-sm text-slate-600 mb-1">
|
| 179 |
+
Image Quality (Pollinations API)
|
| 180 |
+
</label>
|
| 181 |
+
<select
|
| 182 |
+
id="aiImageQualitySelect"
|
| 183 |
+
value={aiImageQuality}
|
| 184 |
+
onChange={(e) => onAiImageQualityChange(e.target.value as AiImageQuality)}
|
| 185 |
+
className="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-shadow text-sm"
|
| 186 |
+
aria-describedby="ai-quality-description"
|
| 187 |
+
>
|
| 188 |
+
{qualityOptions.map((option) => (
|
| 189 |
+
<option key={option.value} value={option.value}>
|
| 190 |
+
{option.label}
|
| 191 |
+
</option>
|
| 192 |
+
))}
|
| 193 |
+
</select>
|
| 194 |
+
<p id="ai-quality-description" className="text-xs text-slate-500 mt-1">
|
| 195 |
+
This quality setting primarily applies to Pollinations-like APIs. Custom endpoints must manage their own quality parameters if needed.
|
| 196 |
+
</p>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div className="flex justify-end gap-3 mt-8">
|
| 201 |
+
<button
|
| 202 |
+
onClick={onClose}
|
| 203 |
+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors text-sm font-medium"
|
| 204 |
+
>
|
| 205 |
+
Done
|
| 206 |
+
</button>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
export default SettingsPanel;
|
components/Toolbar.tsx
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { UndoIcon, RedoIcon, UploadIcon, DownloadIcon, ClearIcon, BrushIcon, EraserIcon, MagicSparkleIcon, SettingsIcon } from './icons';
|
| 3 |
+
|
| 4 |
+
interface ToolbarProps {
|
| 5 |
+
penColor: string;
|
| 6 |
+
onColorChange: (color: string) => void;
|
| 7 |
+
penSize: number;
|
| 8 |
+
onSizeChange: (size: number) => void;
|
| 9 |
+
onUndo: () => void;
|
| 10 |
+
canUndo: boolean;
|
| 11 |
+
onRedo: () => void;
|
| 12 |
+
canRedo: boolean;
|
| 13 |
+
onLoadImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
| 14 |
+
onExportImage: () => void;
|
| 15 |
+
onClearCanvas: () => void;
|
| 16 |
+
isEraserMode: boolean;
|
| 17 |
+
onToggleEraser: () => void;
|
| 18 |
+
onMagicUpload: () => void;
|
| 19 |
+
isMagicUploading: boolean;
|
| 20 |
+
canShare: boolean;
|
| 21 |
+
onToggleSettings: () => void; // New prop for settings
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const Toolbar: React.FC<ToolbarProps> = ({
|
| 25 |
+
penColor,
|
| 26 |
+
onColorChange,
|
| 27 |
+
penSize,
|
| 28 |
+
onSizeChange,
|
| 29 |
+
onUndo,
|
| 30 |
+
canUndo,
|
| 31 |
+
onRedo,
|
| 32 |
+
canRedo,
|
| 33 |
+
onLoadImage,
|
| 34 |
+
onExportImage,
|
| 35 |
+
onClearCanvas,
|
| 36 |
+
isEraserMode,
|
| 37 |
+
onToggleEraser,
|
| 38 |
+
onMagicUpload,
|
| 39 |
+
isMagicUploading,
|
| 40 |
+
canShare,
|
| 41 |
+
onToggleSettings, // New prop
|
| 42 |
+
}) => {
|
| 43 |
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
| 44 |
+
|
| 45 |
+
const handleUploadClick = () => {
|
| 46 |
+
fileInputRef.current?.click();
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="bg-slate-200 p-3 shadow-lg flex flex-col items-start justify-center gap-y-2 sticky top-0 z-50 border-b border-slate-300 w-full">
|
| 51 |
+
{/* First Row: Action Buttons */}
|
| 52 |
+
<div className="flex flex-wrap items-center justify-start gap-x-3 gap-y-2">
|
| 53 |
+
<button
|
| 54 |
+
onClick={onUndo}
|
| 55 |
+
disabled={!canUndo}
|
| 56 |
+
title="Undo"
|
| 57 |
+
className="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-gray-300"
|
| 58 |
+
aria-label="Undo last action"
|
| 59 |
+
>
|
| 60 |
+
<UndoIcon className="w-5 h-5 text-gray-700" />
|
| 61 |
+
</button>
|
| 62 |
+
|
| 63 |
+
<button
|
| 64 |
+
onClick={onRedo}
|
| 65 |
+
disabled={!canRedo}
|
| 66 |
+
title="Redo"
|
| 67 |
+
className="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-gray-300"
|
| 68 |
+
aria-label="Redo last undone action"
|
| 69 |
+
>
|
| 70 |
+
<RedoIcon className="w-5 h-5 text-gray-700" />
|
| 71 |
+
</button>
|
| 72 |
+
|
| 73 |
+
<input
|
| 74 |
+
type="file"
|
| 75 |
+
ref={fileInputRef}
|
| 76 |
+
onChange={onLoadImage}
|
| 77 |
+
accept="image/png, image/jpeg"
|
| 78 |
+
className="hidden"
|
| 79 |
+
aria-label="Load image from file"
|
| 80 |
+
/>
|
| 81 |
+
<button
|
| 82 |
+
onClick={handleUploadClick}
|
| 83 |
+
title="Load Image"
|
| 84 |
+
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 85 |
+
aria-label="Load image"
|
| 86 |
+
>
|
| 87 |
+
<UploadIcon className="w-5 h-5 text-gray-700" />
|
| 88 |
+
</button>
|
| 89 |
+
|
| 90 |
+
<button
|
| 91 |
+
onClick={onExportImage}
|
| 92 |
+
title="Export Image"
|
| 93 |
+
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 94 |
+
aria-label="Export canvas as image"
|
| 95 |
+
>
|
| 96 |
+
<DownloadIcon className="w-5 h-5 text-gray-700" />
|
| 97 |
+
</button>
|
| 98 |
+
|
| 99 |
+
<button
|
| 100 |
+
onClick={onMagicUpload}
|
| 101 |
+
title="Share Canvas & Edit with AI"
|
| 102 |
+
disabled={isMagicUploading || !canShare}
|
| 103 |
+
className="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-gray-300"
|
| 104 |
+
aria-label="Share canvas by uploading and open AI edit options"
|
| 105 |
+
>
|
| 106 |
+
<MagicSparkleIcon className="w-5 h-5 text-purple-600" />
|
| 107 |
+
</button>
|
| 108 |
+
|
| 109 |
+
<button
|
| 110 |
+
onClick={onClearCanvas}
|
| 111 |
+
title="Clear Canvas"
|
| 112 |
+
className="p-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors tool-button"
|
| 113 |
+
aria-label="Clear canvas"
|
| 114 |
+
>
|
| 115 |
+
<ClearIcon className="w-5 h-5" />
|
| 116 |
+
</button>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
{/* Second Row: Drawing Tools & Settings */}
|
| 120 |
+
<div className="flex flex-wrap items-center justify-start gap-x-3 gap-y-2 mt-2">
|
| 121 |
+
<button
|
| 122 |
+
onClick={onToggleEraser}
|
| 123 |
+
title={isEraserMode ? "Switch to Brush" : "Switch to Eraser"}
|
| 124 |
+
className={`p-2 rounded-md transition-colors ${isEraserMode ? 'bg-blue-500 text-white hover:bg-blue-600' : ' hover:bg-gray-100 text-gray-700 border-gray-300'}`}
|
| 125 |
+
aria-label={isEraserMode ? "Switch to Brush mode" : "Switch to Eraser mode"}
|
| 126 |
+
aria-pressed={isEraserMode}
|
| 127 |
+
>
|
| 128 |
+
{isEraserMode ? <BrushIcon className="w-5 h-5" /> : <EraserIcon className="w-5 h-5" />}
|
| 129 |
+
</button>
|
| 130 |
+
|
| 131 |
+
<div className="flex items-center gap-2 p-1 rounded-md hover:bg-slate-300 transition-colors">
|
| 132 |
+
<label htmlFor="penColor" className="text-sm font-medium text-gray-700 sr-only">Color:</label>
|
| 133 |
+
<input
|
| 134 |
+
type="color"
|
| 135 |
+
id="penColor"
|
| 136 |
+
title="Pen Color"
|
| 137 |
+
value={penColor}
|
| 138 |
+
onChange={(e) => onColorChange(e.target.value)}
|
| 139 |
+
className={`w-8 h-8 rounded-full cursor-pointer border-2 ${isEraserMode ? 'border-gray-400 opacity-50' : 'border-white shadow-sm'}`}
|
| 140 |
+
disabled={isEraserMode}
|
| 141 |
+
aria-label="Select pen color"
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<div className="flex items-center gap-2 p-1 rounded-md hover:bg-slate-300 transition-colors">
|
| 146 |
+
<label htmlFor="penSize" className="text-sm font-medium text-gray-700 sr-only">Size:</label>
|
| 147 |
+
<input
|
| 148 |
+
type="range"
|
| 149 |
+
id="penSize"
|
| 150 |
+
title={`Pen Size: ${penSize}`}
|
| 151 |
+
min="1"
|
| 152 |
+
max="50"
|
| 153 |
+
value={penSize}
|
| 154 |
+
onChange={(e) => onSizeChange(parseInt(e.target.value, 10))}
|
| 155 |
+
className="w-24 md:w-32 cursor-pointer accent-blue-600"
|
| 156 |
+
aria-label={`Pen size ${penSize} pixels`}
|
| 157 |
+
aria-valuemin={1}
|
| 158 |
+
aria-valuemax={50}
|
| 159 |
+
aria-valuenow={penSize}
|
| 160 |
+
/>
|
| 161 |
+
<span className="text-xs text-gray-600 w-6 text-right" aria-hidden="true">{penSize}</span>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<button
|
| 165 |
+
onClick={onToggleSettings}
|
| 166 |
+
title="Open Settings"
|
| 167 |
+
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 168 |
+
aria-label="Open application settings"
|
| 169 |
+
>
|
| 170 |
+
<SettingsIcon className="w-5 h-5 text-gray-700" />
|
| 171 |
+
</button>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
);
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
export default Toolbar;
|
components/ZoomSlider.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
|
| 4 |
+
interface ZoomSliderProps {
|
| 5 |
+
zoomLevel: number;
|
| 6 |
+
onZoomChange: (newZoomLevel: number) => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const ZoomSlider: React.FC<ZoomSliderProps> = ({ zoomLevel, onZoomChange }) => {
|
| 10 |
+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 11 |
+
onZoomChange(parseFloat(event.target.value));
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const zoomPercentage = Math.round(zoomLevel * 100);
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div
|
| 18 |
+
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30 bg-slate-700 bg-opacity-80 backdrop-blur-sm text-white p-3 rounded-lg shadow-xl flex items-center space-x-3"
|
| 19 |
+
role="toolbar"
|
| 20 |
+
aria-label="Zoom controls"
|
| 21 |
+
>
|
| 22 |
+
<label htmlFor="zoom-slider" className="text-xs font-medium whitespace-nowrap">
|
| 23 |
+
Zoom: <span className="font-bold">{zoomPercentage}%</span>
|
| 24 |
+
</label>
|
| 25 |
+
<input
|
| 26 |
+
type="range"
|
| 27 |
+
id="zoom-slider"
|
| 28 |
+
min="0.2" // 20%
|
| 29 |
+
max="3.0" // 300%
|
| 30 |
+
step="0.01"
|
| 31 |
+
value={zoomLevel}
|
| 32 |
+
onChange={handleChange}
|
| 33 |
+
className="w-32 md:w-40 h-2 bg-slate-500 rounded-lg appearance-none cursor-pointer accent-sky-500"
|
| 34 |
+
aria-valuemin={20}
|
| 35 |
+
aria-valuemax={300}
|
| 36 |
+
aria-valuenow={zoomPercentage}
|
| 37 |
+
aria-label={`Current zoom level ${zoomPercentage} percent`}
|
| 38 |
+
/>
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
export default ZoomSlider;
|
components/icons.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
export const UndoIcon: React.FC<{className?: string}> = ({className}) => (
|
| 4 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 5 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
| 6 |
+
</svg>
|
| 7 |
+
);
|
| 8 |
+
|
| 9 |
+
export const RedoIcon: React.FC<{className?: string}> = ({className}) => (
|
| 10 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 11 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="m15 15 6-6m0 0-6-6m6 6H9a6 6 0 0 0 0 12h3" />
|
| 12 |
+
</svg>
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
export const UploadIcon: React.FC<{className?: string}> = ({className}) => (
|
| 16 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 17 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
| 18 |
+
</svg>
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
export const DownloadIcon: React.FC<{className?: string}> = ({className}) => (
|
| 22 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 23 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
| 24 |
+
</svg>
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
export const ClearIcon: React.FC<{className?: string}> = ({className}) => (
|
| 28 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 29 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12.56 0c.342.052.682.107 1.022.166m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
| 30 |
+
</svg>
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
export const BrushIcon: React.FC<{className?: string}> = ({className}) => (
|
| 34 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 35 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
| 36 |
+
</svg>
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
export const EraserIcon: React.FC<{className?: string}> = ({className}) => (
|
| 40 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className || "w-6 h-6"}>
|
| 41 |
+
<path d="M16.24,3.56L20.44,7.76L7.76,20.44L3.56,16.24L16.24,3.56M22.24,6.34L20.07,4.17C19.68,3.78 19.05,3.78 18.66,4.17L17.13,5.69L21.34,9.89L22.83,8.41C23.22,8 23.22,7.37 22.83,6.98L22.24,6.34Z" />
|
| 42 |
+
</svg>
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
export const MagicSparkleIcon: React.FC<{className?: string}> = ({className}) => (
|
| 46 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 47 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L1.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.25 7.5l.813 2.846a4.5 4.5 0 0 1 2.187 2.187L24 13.5l-2.846.813a4.5 4.5 0 0 1-2.187 2.187L18.25 19.5l-.813-2.846a4.5 4.5 0 0 1-2.187-2.187L12.5 13.5l2.846-.813a4.5 4.5 0 0 1 2.187-2.187L18.25 7.5Z" />
|
| 48 |
+
</svg>
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
export const CloseIcon: React.FC<{className?: string}> = ({className}) => (
|
| 52 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 53 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
| 54 |
+
</svg>
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
export const SettingsIcon: React.FC<{className?: string}> = ({className}) => (
|
| 58 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 59 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.78.93l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527a1.125 1.125 0 0 1-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.93l.15-.893Z" />
|
| 60 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
| 61 |
+
</svg>
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
export const FullscreenEnterIcon: React.FC<{className?: string}> = ({className}) => (
|
| 65 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 66 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m4.5 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
| 67 |
+
</svg>
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
export const FullscreenExitIcon: React.FC<{className?: string}> = ({className}) => (
|
| 71 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-6 h-6"}>
|
| 72 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5M15 15l5.25 5.25" />
|
| 73 |
+
</svg>
|
| 74 |
+
);
|
constants.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DB_NAME = 'ReactPaintDB';
|
| 2 |
+
export const CANVAS_STORE_NAME = 'canvasState';
|
| 3 |
+
export const CANVAS_AUTOSAVE_KEY = 'autosavedCanvas';
|
| 4 |
+
export const MAX_HISTORY_STEPS = 100;
|
| 5 |
+
export const DEFAULT_PEN_COLOR = '#000000';
|
| 6 |
+
export const DEFAULT_PEN_SIZE = 5;
|
| 7 |
+
export const DEFAULT_CANVAS_WIDTH = 1600;
|
| 8 |
+
export const DEFAULT_CANVAS_HEIGHT = 1600;
|
| 9 |
+
export const DEFAULT_AI_API_ENDPOINT = 'https://geminisimpleget.deno.dev/prompt/{prompt}?image={imgurl.url}';
|
hooks/useAiFeatures.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
import { ToastMessage } from './useToasts';
|
| 3 |
+
import { dataURLtoBlob, calculateSHA256, copyToClipboard } from '../utils/canvasUtils';
|
| 4 |
+
import { CanvasHistoryHook } from './useCanvasHistory';
|
| 5 |
+
|
| 6 |
+
const SHARE_API_URL = 'https://sharefile.suisuy.eu.org';
|
| 7 |
+
const MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
| 8 |
+
|
| 9 |
+
export type AiImageQuality = 'low' | 'medium' | 'hd';
|
| 10 |
+
|
| 11 |
+
interface AiFeaturesHook {
|
| 12 |
+
isMagicUploading: boolean;
|
| 13 |
+
showAiEditModal: boolean;
|
| 14 |
+
aiPrompt: string;
|
| 15 |
+
isGeneratingAiImage: boolean;
|
| 16 |
+
sharedImageUrlForAi: string | null;
|
| 17 |
+
aiEditError: string | null;
|
| 18 |
+
handleMagicUpload: () => Promise<void>;
|
| 19 |
+
handleGenerateAiImage: () => Promise<void>;
|
| 20 |
+
handleCancelAiEdit: () => void;
|
| 21 |
+
setAiPrompt: React.Dispatch<React.SetStateAction<string>>;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface UseAiFeaturesProps {
|
| 25 |
+
currentDataURL: string | null;
|
| 26 |
+
showToast: (message: string, type: ToastMessage['type']) => void;
|
| 27 |
+
updateCanvasState: CanvasHistoryHook['updateCanvasState'];
|
| 28 |
+
setZoomLevel: (zoom: number) => void;
|
| 29 |
+
aiImageQuality: AiImageQuality;
|
| 30 |
+
aiApiEndpoint: string;
|
| 31 |
+
currentCanvasWidth: number; // Added
|
| 32 |
+
currentCanvasHeight: number; // Added
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export const useAiFeatures = ({
|
| 36 |
+
currentDataURL,
|
| 37 |
+
showToast,
|
| 38 |
+
updateCanvasState,
|
| 39 |
+
setZoomLevel,
|
| 40 |
+
aiImageQuality,
|
| 41 |
+
aiApiEndpoint,
|
| 42 |
+
currentCanvasWidth, // Destructure
|
| 43 |
+
currentCanvasHeight, // Destructure
|
| 44 |
+
}: UseAiFeaturesProps): AiFeaturesHook => {
|
| 45 |
+
const [isMagicUploading, setIsMagicUploading] = useState<boolean>(false);
|
| 46 |
+
const [showAiEditModal, setShowAiEditModal] = useState<boolean>(false);
|
| 47 |
+
const [aiPrompt, setAiPrompt] = useState<string>('');
|
| 48 |
+
const [isGeneratingAiImage, setIsGeneratingAiImage] = useState<boolean>(false);
|
| 49 |
+
const [sharedImageUrlForAi, setSharedImageUrlForAi] = useState<string | null>(null);
|
| 50 |
+
const [aiEditError, setAiEditError] = useState<string | null>(null);
|
| 51 |
+
|
| 52 |
+
const loadAiImageOntoCanvas = useCallback((aiImageDataUrl: string) => {
|
| 53 |
+
const img = new Image();
|
| 54 |
+
img.onload = () => {
|
| 55 |
+
const targetCanvasWidth = currentCanvasWidth;
|
| 56 |
+
const targetCanvasHeight = currentCanvasHeight;
|
| 57 |
+
|
| 58 |
+
// Calculate dimensions to draw the AI image, maintaining aspect ratio
|
| 59 |
+
let drawWidth = img.naturalWidth;
|
| 60 |
+
let drawHeight = img.naturalHeight;
|
| 61 |
+
|
| 62 |
+
const canvasAspectRatio = targetCanvasWidth / targetCanvasHeight;
|
| 63 |
+
const imageAspectRatio = img.naturalWidth / img.naturalHeight;
|
| 64 |
+
|
| 65 |
+
if (imageAspectRatio > canvasAspectRatio) {
|
| 66 |
+
// Image is wider relative to canvas: fit by width
|
| 67 |
+
drawWidth = targetCanvasWidth;
|
| 68 |
+
drawHeight = targetCanvasWidth / imageAspectRatio;
|
| 69 |
+
} else {
|
| 70 |
+
// Image is taller or same aspect ratio: fit by height
|
| 71 |
+
drawHeight = targetCanvasHeight;
|
| 72 |
+
drawWidth = targetCanvasHeight * imageAspectRatio;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Ensure dimensions are at least 1px and integers
|
| 76 |
+
drawWidth = Math.max(1, Math.floor(drawWidth));
|
| 77 |
+
drawHeight = Math.max(1, Math.floor(drawHeight));
|
| 78 |
+
|
| 79 |
+
const drawX = (targetCanvasWidth - drawWidth) / 2;
|
| 80 |
+
const drawY = (targetCanvasHeight - drawHeight) / 2;
|
| 81 |
+
|
| 82 |
+
const tempCanvas = document.createElement('canvas');
|
| 83 |
+
tempCanvas.width = targetCanvasWidth;
|
| 84 |
+
tempCanvas.height = targetCanvasHeight;
|
| 85 |
+
const tempCtx = tempCanvas.getContext('2d');
|
| 86 |
+
|
| 87 |
+
if (tempCtx) {
|
| 88 |
+
tempCtx.fillStyle = '#FFFFFF'; // White background for the canvas
|
| 89 |
+
tempCtx.fillRect(0, 0, targetCanvasWidth, targetCanvasHeight);
|
| 90 |
+
tempCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
| 91 |
+
const newCanvasState = tempCanvas.toDataURL('image/png');
|
| 92 |
+
|
| 93 |
+
// Use current canvas dimensions, not AI image's natural dimensions
|
| 94 |
+
updateCanvasState(newCanvasState, targetCanvasWidth, targetCanvasHeight);
|
| 95 |
+
|
| 96 |
+
setShowAiEditModal(false);
|
| 97 |
+
showToast('AI image applied!', 'success');
|
| 98 |
+
setZoomLevel(1);
|
| 99 |
+
setAiPrompt('');
|
| 100 |
+
setSharedImageUrlForAi(null);
|
| 101 |
+
} else {
|
| 102 |
+
setAiEditError('Failed to create drawing context for AI image.');
|
| 103 |
+
}
|
| 104 |
+
setIsGeneratingAiImage(false);
|
| 105 |
+
};
|
| 106 |
+
img.onerror = () => {
|
| 107 |
+
setAiEditError('Failed to load the generated AI image. It might be an invalid image format.');
|
| 108 |
+
setIsGeneratingAiImage(false);
|
| 109 |
+
};
|
| 110 |
+
img.crossOrigin = "anonymous";
|
| 111 |
+
img.src = aiImageDataUrl;
|
| 112 |
+
}, [showToast, updateCanvasState, setZoomLevel, currentCanvasWidth, currentCanvasHeight]); // Added currentCanvasWidth, currentCanvasHeight
|
| 113 |
+
|
| 114 |
+
const handleMagicUpload = useCallback(async () => {
|
| 115 |
+
if (isMagicUploading || !currentDataURL) {
|
| 116 |
+
showToast('No canvas content to share.', 'info');
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
setIsMagicUploading(true);
|
| 121 |
+
setAiEditError(null);
|
| 122 |
+
showToast('Uploading image...', 'info');
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
const blob = await dataURLtoBlob(currentDataURL);
|
| 126 |
+
|
| 127 |
+
if (blob.size > MAX_UPLOAD_SIZE_BYTES) {
|
| 128 |
+
showToast(`Image too large (${(blob.size / 1024 / 1024).toFixed(2)}MB). Max 50MB.`, 'error');
|
| 129 |
+
setIsMagicUploading(false);
|
| 130 |
+
return;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const hash = await calculateSHA256(blob);
|
| 134 |
+
const filename = `tempaint_${hash}.png`;
|
| 135 |
+
const uploadUrl = `${SHARE_API_URL}/${filename}`;
|
| 136 |
+
|
| 137 |
+
const response = await fetch(uploadUrl, {
|
| 138 |
+
method: 'POST',
|
| 139 |
+
headers: { 'Content-Type': blob.type || 'image/png' },
|
| 140 |
+
body: blob,
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
if (!response.ok) {
|
| 144 |
+
let errorMsg = `Upload failed: ${response.statusText}`;
|
| 145 |
+
try { const errorBody = await response.text(); errorMsg = `Upload failed: ${errorBody || response.statusText}`; } catch (e) { /* ignore */ }
|
| 146 |
+
throw new Error(errorMsg);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const returnedObjectName = await response.text();
|
| 150 |
+
const finalShareUrl = `https://pub-cb2c87ea7373408abb1050dd43e3cd8e.r2.dev/${returnedObjectName}`;
|
| 151 |
+
|
| 152 |
+
setSharedImageUrlForAi(finalShareUrl);
|
| 153 |
+
setShowAiEditModal(true);
|
| 154 |
+
setAiPrompt('');
|
| 155 |
+
setAiEditError(null);
|
| 156 |
+
|
| 157 |
+
} catch (error: any) {
|
| 158 |
+
console.error('Magic upload error:', error);
|
| 159 |
+
showToast(error.message || 'Magic upload failed. Check console.', 'error');
|
| 160 |
+
} finally {
|
| 161 |
+
setIsMagicUploading(false);
|
| 162 |
+
}
|
| 163 |
+
}, [isMagicUploading, currentDataURL, showToast]);
|
| 164 |
+
|
| 165 |
+
const handleGenerateAiImage = useCallback(async () => {
|
| 166 |
+
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
|
| 167 |
+
setAiEditError('Please enter a prompt and ensure an image was uploaded.');
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
setIsGeneratingAiImage(true);
|
| 171 |
+
setAiEditError(null);
|
| 172 |
+
showToast('Generating AI image...', 'info');
|
| 173 |
+
|
| 174 |
+
const encodedPrompt = encodeURIComponent(aiPrompt);
|
| 175 |
+
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
|
| 176 |
+
|
| 177 |
+
let finalApiUrl = aiApiEndpoint
|
| 178 |
+
.replace('{prompt}', encodedPrompt)
|
| 179 |
+
.replace('{imgurl.url}', encodedImageUrl);
|
| 180 |
+
|
| 181 |
+
if (aiApiEndpoint.includes('pollinations.ai')) {
|
| 182 |
+
const pollinationsParams = new URLSearchParams({
|
| 183 |
+
model: 'gptimage',
|
| 184 |
+
private: 'true',
|
| 185 |
+
quality: aiImageQuality,
|
| 186 |
+
safe: 'false',
|
| 187 |
+
transparent: 'false',
|
| 188 |
+
width: '1024',
|
| 189 |
+
height: '1024',
|
| 190 |
+
seed: Date.now().toString(),
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
if (finalApiUrl.endsWith('/')) {
|
| 194 |
+
finalApiUrl = `${finalApiUrl}${encodedPrompt}?image=${encodedImageUrl}&${pollinationsParams.toString()}`;
|
| 195 |
+
} else if (finalApiUrl.includes('?')) {
|
| 196 |
+
finalApiUrl = `${finalApiUrl}&image=${encodedImageUrl}&${pollinationsParams.toString()}`;
|
| 197 |
+
} else {
|
| 198 |
+
finalApiUrl = `${finalApiUrl}?image=${encodedImageUrl}&${pollinationsParams.toString()}`;
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
try {
|
| 203 |
+
const response = await fetch(finalApiUrl);
|
| 204 |
+
if (!response.ok) {
|
| 205 |
+
let errorMsg = `AI image generation failed: ${response.status} ${response.statusText}`;
|
| 206 |
+
try {
|
| 207 |
+
const errorBody = await response.text();
|
| 208 |
+
if (errorBody && !errorBody.toLowerCase().includes('<html')) {
|
| 209 |
+
errorMsg += ` - ${errorBody.substring(0,100)}`;
|
| 210 |
+
}
|
| 211 |
+
} catch(e) { /* ignore if can't read body */ }
|
| 212 |
+
throw new Error(errorMsg);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
const imageBlob = await response.blob();
|
| 216 |
+
if (!imageBlob.type.startsWith('image/')) {
|
| 217 |
+
throw new Error('AI service did not return a valid image. Please try a different prompt or check the API endpoint.');
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
const reader = new FileReader();
|
| 221 |
+
reader.onloadend = () => {
|
| 222 |
+
if (typeof reader.result === 'string') {
|
| 223 |
+
loadAiImageOntoCanvas(reader.result);
|
| 224 |
+
} else {
|
| 225 |
+
setAiEditError('Failed to read AI image data as string.');
|
| 226 |
+
setIsGeneratingAiImage(false);
|
| 227 |
+
}
|
| 228 |
+
};
|
| 229 |
+
reader.onerror = () => {
|
| 230 |
+
setAiEditError('Failed to read AI image data.');
|
| 231 |
+
setIsGeneratingAiImage(false);
|
| 232 |
+
}
|
| 233 |
+
reader.readAsDataURL(imageBlob);
|
| 234 |
+
|
| 235 |
+
} catch (error: any) {
|
| 236 |
+
console.error('AI image generation error:', error);
|
| 237 |
+
setAiEditError(error.message || 'An unknown error occurred during AI image generation.');
|
| 238 |
+
setIsGeneratingAiImage(false);
|
| 239 |
+
}
|
| 240 |
+
}, [aiPrompt, sharedImageUrlForAi, showToast, loadAiImageOntoCanvas, aiImageQuality, aiApiEndpoint]);
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
const handleCancelAiEdit = () => {
|
| 244 |
+
setShowAiEditModal(false);
|
| 245 |
+
if (sharedImageUrlForAi) {
|
| 246 |
+
copyToClipboard(sharedImageUrlForAi, (msg, type) => showToast(msg,type as 'info' | 'error')).then(copied => {
|
| 247 |
+
if(copied) {
|
| 248 |
+
showToast(`Image uploaded! URL: ${sharedImageUrlForAi} (Copied!)`, 'success');
|
| 249 |
+
}
|
| 250 |
+
});
|
| 251 |
+
}
|
| 252 |
+
setAiPrompt('');
|
| 253 |
+
setAiEditError(null);
|
| 254 |
+
setSharedImageUrlForAi(null);
|
| 255 |
+
};
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
isMagicUploading,
|
| 259 |
+
showAiEditModal,
|
| 260 |
+
aiPrompt,
|
| 261 |
+
isGeneratingAiImage,
|
| 262 |
+
sharedImageUrlForAi,
|
| 263 |
+
aiEditError,
|
| 264 |
+
handleMagicUpload,
|
| 265 |
+
handleGenerateAiImage,
|
| 266 |
+
handleCancelAiEdit,
|
| 267 |
+
setAiPrompt,
|
| 268 |
+
};
|
| 269 |
+
};
|
hooks/useCanvasFileUtils.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback } from 'react';
|
| 2 |
+
import { getBlankCanvasDataURL } from '../utils/canvasUtils';
|
| 3 |
+
import { CanvasHistoryHook } from './useCanvasHistory';
|
| 4 |
+
import { ConfirmModalHook } from './useConfirmModal';
|
| 5 |
+
import { ToastMessage } from './useToasts';
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
interface UseCanvasFileUtilsProps {
|
| 9 |
+
canvasState: {
|
| 10 |
+
currentDataURL: string | null;
|
| 11 |
+
canvasWidth: number;
|
| 12 |
+
canvasHeight: number;
|
| 13 |
+
};
|
| 14 |
+
historyActions: {
|
| 15 |
+
updateCanvasState: CanvasHistoryHook['updateCanvasState'];
|
| 16 |
+
};
|
| 17 |
+
uiActions: {
|
| 18 |
+
showToast: (message: string, type: ToastMessage['type']) => void;
|
| 19 |
+
setZoomLevel: (zoom: number) => void;
|
| 20 |
+
};
|
| 21 |
+
confirmModalActions: {
|
| 22 |
+
requestConfirmation: ConfirmModalHook['requestConfirmation'];
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface CanvasFileUtilsHook {
|
| 27 |
+
handleLoadImageFile: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
| 28 |
+
handleExportImage: () => void;
|
| 29 |
+
handleClearCanvas: () => void;
|
| 30 |
+
handleCanvasSizeChange: (newWidth: number, newHeight: number) => void;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export const useCanvasFileUtils = ({
|
| 34 |
+
canvasState,
|
| 35 |
+
historyActions,
|
| 36 |
+
uiActions,
|
| 37 |
+
confirmModalActions,
|
| 38 |
+
}: UseCanvasFileUtilsProps): CanvasFileUtilsHook => {
|
| 39 |
+
const { currentDataURL, canvasWidth, canvasHeight } = canvasState;
|
| 40 |
+
const { updateCanvasState } = historyActions;
|
| 41 |
+
const { showToast, setZoomLevel } = uiActions;
|
| 42 |
+
const { requestConfirmation } = confirmModalActions;
|
| 43 |
+
|
| 44 |
+
const handleLoadImageFile = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
| 45 |
+
const file = event.target.files?.[0];
|
| 46 |
+
if (file) {
|
| 47 |
+
const reader = new FileReader();
|
| 48 |
+
reader.onload = (e) => {
|
| 49 |
+
const imageDataUrl = e.target?.result as string;
|
| 50 |
+
if (imageDataUrl) {
|
| 51 |
+
const img = new Image();
|
| 52 |
+
img.onload = () => {
|
| 53 |
+
let imageToDrawWidth = img.naturalWidth;
|
| 54 |
+
let imageToDrawHeight = img.naturalHeight;
|
| 55 |
+
|
| 56 |
+
if (img.naturalWidth > canvasWidth || img.naturalHeight > canvasHeight) {
|
| 57 |
+
const widthRatio = canvasWidth / img.naturalWidth;
|
| 58 |
+
const heightRatio = canvasHeight / img.naturalHeight;
|
| 59 |
+
const scaleFactor = Math.min(widthRatio, heightRatio);
|
| 60 |
+
imageToDrawWidth = Math.max(1, Math.floor(img.naturalWidth * scaleFactor));
|
| 61 |
+
imageToDrawHeight = Math.max(1, Math.floor(img.naturalHeight * scaleFactor));
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const finalCanvasWidth = canvasWidth;
|
| 65 |
+
const finalCanvasHeight = canvasHeight;
|
| 66 |
+
|
| 67 |
+
const tempCanvas = document.createElement('canvas');
|
| 68 |
+
tempCanvas.width = finalCanvasWidth;
|
| 69 |
+
tempCanvas.height = finalCanvasHeight;
|
| 70 |
+
const tempCtx = tempCanvas.getContext('2d');
|
| 71 |
+
|
| 72 |
+
if (tempCtx) {
|
| 73 |
+
tempCtx.fillStyle = '#FFFFFF';
|
| 74 |
+
tempCtx.fillRect(0, 0, finalCanvasWidth, finalCanvasHeight);
|
| 75 |
+
|
| 76 |
+
const drawX = (finalCanvasWidth - imageToDrawWidth) / 2;
|
| 77 |
+
const drawY = (finalCanvasHeight - imageToDrawHeight) / 2;
|
| 78 |
+
|
| 79 |
+
tempCtx.drawImage(img, drawX, drawY, imageToDrawWidth, imageToDrawHeight);
|
| 80 |
+
const compositeDataURL = tempCanvas.toDataURL('image/png');
|
| 81 |
+
|
| 82 |
+
updateCanvasState(compositeDataURL, finalCanvasWidth, finalCanvasHeight);
|
| 83 |
+
setZoomLevel(0.5);
|
| 84 |
+
showToast('Image loaded successfully.', 'success');
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
img.onerror = () => showToast("Error loading image file for processing.", 'error');
|
| 88 |
+
img.src = imageDataUrl;
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
reader.onerror = () => showToast("Error reading image file.", 'error');
|
| 92 |
+
reader.readAsDataURL(file);
|
| 93 |
+
if(event.target) event.target.value = '';
|
| 94 |
+
}
|
| 95 |
+
}, [canvasWidth, canvasHeight, updateCanvasState, showToast, setZoomLevel]);
|
| 96 |
+
|
| 97 |
+
const handleExportImage = useCallback(() => {
|
| 98 |
+
if (currentDataURL) {
|
| 99 |
+
const link = document.createElement('a');
|
| 100 |
+
link.href = currentDataURL;
|
| 101 |
+
link.download = `paint-masterpiece-${Date.now()}.png`;
|
| 102 |
+
document.body.appendChild(link);
|
| 103 |
+
link.click();
|
| 104 |
+
document.body.removeChild(link);
|
| 105 |
+
showToast('Image exported!', 'success');
|
| 106 |
+
} else {
|
| 107 |
+
showToast('No image to export.', 'info');
|
| 108 |
+
}
|
| 109 |
+
}, [currentDataURL, showToast]);
|
| 110 |
+
|
| 111 |
+
const handleClearCanvas = useCallback(() => {
|
| 112 |
+
requestConfirmation(
|
| 113 |
+
"Clear Canvas",
|
| 114 |
+
"Are you sure you want to clear the entire canvas? This action cannot be undone from history.",
|
| 115 |
+
() => {
|
| 116 |
+
const blankCanvas = getBlankCanvasDataURL(canvasWidth, canvasHeight);
|
| 117 |
+
updateCanvasState(blankCanvas, canvasWidth, canvasHeight);
|
| 118 |
+
setZoomLevel(0.5);
|
| 119 |
+
showToast("Canvas cleared.", "info");
|
| 120 |
+
},
|
| 121 |
+
{ isDestructive: true }
|
| 122 |
+
);
|
| 123 |
+
}, [requestConfirmation, canvasWidth, canvasHeight, updateCanvasState, setZoomLevel, showToast]);
|
| 124 |
+
|
| 125 |
+
const handleCanvasSizeChange = useCallback((newWidth: number, newHeight: number) => {
|
| 126 |
+
if (newWidth === canvasWidth && newHeight === canvasHeight) {
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
requestConfirmation(
|
| 130 |
+
"Confirm Canvas Size Change",
|
| 131 |
+
"Changing canvas size will clear the current drawing and history. Are you sure you want to continue?",
|
| 132 |
+
() => {
|
| 133 |
+
const blankCanvas = getBlankCanvasDataURL(newWidth, newHeight);
|
| 134 |
+
updateCanvasState(blankCanvas, newWidth, newHeight);
|
| 135 |
+
setZoomLevel(0.5);
|
| 136 |
+
showToast(`Canvas resized to ${newWidth}x${newHeight}px. Drawing cleared.`, 'info');
|
| 137 |
+
},
|
| 138 |
+
{ isDestructive: true }
|
| 139 |
+
);
|
| 140 |
+
}, [canvasWidth, canvasHeight, requestConfirmation, updateCanvasState, setZoomLevel, showToast]);
|
| 141 |
+
|
| 142 |
+
return { handleLoadImageFile, handleExportImage, handleClearCanvas, handleCanvasSizeChange };
|
| 143 |
+
};
|
hooks/useCanvasHistory.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 3 |
+
import { saveCanvasState as dbSaveCanvasState, loadCanvasState as dbLoadCanvasState, LoadedCanvasState } from '../services/dbService';
|
| 4 |
+
import { MAX_HISTORY_STEPS, DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT } from '../constants';
|
| 5 |
+
import { getBlankCanvasDataURL } from '../utils/canvasUtils';
|
| 6 |
+
|
| 7 |
+
export interface CanvasHistoryHook {
|
| 8 |
+
historyStack: string[];
|
| 9 |
+
currentHistoryIndex: number;
|
| 10 |
+
canvasWidth: number;
|
| 11 |
+
canvasHeight: number;
|
| 12 |
+
isLoading: boolean;
|
| 13 |
+
currentDataURL: string | null;
|
| 14 |
+
handleDrawEnd: (dataURL: string, newWidth?: number, newHeight?: number) => void;
|
| 15 |
+
handleUndo: () => void;
|
| 16 |
+
handleRedo: () => void;
|
| 17 |
+
updateCanvasState: (dataURL: string, width: number, height: number) => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
export const useCanvasHistory = (): CanvasHistoryHook => {
|
| 22 |
+
const [historyStack, setHistoryStack] = useState<string[]>([]);
|
| 23 |
+
const [currentHistoryIndex, setCurrentHistoryIndex] = useState<number>(-1);
|
| 24 |
+
const [canvasWidth, setCanvasWidth] = useState<number>(DEFAULT_CANVAS_WIDTH);
|
| 25 |
+
const [canvasHeight, setCanvasHeight] = useState<number>(DEFAULT_CANVAS_HEIGHT);
|
| 26 |
+
const [isLoading, setIsLoading] = useState<boolean>(true);
|
| 27 |
+
|
| 28 |
+
const currentDataURL = historyStack.length > 0 && currentHistoryIndex >= 0 ? historyStack[currentHistoryIndex] : null;
|
| 29 |
+
|
| 30 |
+
const loadInitialCanvas = useCallback(async () => {
|
| 31 |
+
setIsLoading(true);
|
| 32 |
+
const savedState: LoadedCanvasState | null = await dbLoadCanvasState();
|
| 33 |
+
if (savedState && savedState.dataURL) { // Ensure dataURL exists
|
| 34 |
+
setCanvasWidth(savedState.width);
|
| 35 |
+
setCanvasHeight(savedState.height);
|
| 36 |
+
setHistoryStack([savedState.dataURL]);
|
| 37 |
+
setCurrentHistoryIndex(0);
|
| 38 |
+
} else {
|
| 39 |
+
const initialWidth = DEFAULT_CANVAS_WIDTH;
|
| 40 |
+
const initialHeight = DEFAULT_CANVAS_HEIGHT;
|
| 41 |
+
setCanvasWidth(initialWidth);
|
| 42 |
+
setCanvasHeight(initialHeight);
|
| 43 |
+
const blankCanvas = getBlankCanvasDataURL(initialWidth, initialHeight);
|
| 44 |
+
setHistoryStack([blankCanvas]);
|
| 45 |
+
setCurrentHistoryIndex(0);
|
| 46 |
+
await dbSaveCanvasState(blankCanvas, initialWidth, initialHeight);
|
| 47 |
+
}
|
| 48 |
+
setIsLoading(false);
|
| 49 |
+
}, []);
|
| 50 |
+
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
loadInitialCanvas();
|
| 53 |
+
}, [loadInitialCanvas]);
|
| 54 |
+
|
| 55 |
+
const handleDrawEnd = useCallback((dataURL: string, newWidth?: number, newHeight?: number) => {
|
| 56 |
+
const effectiveWidth = newWidth ?? canvasWidth;
|
| 57 |
+
const effectiveHeight = newHeight ?? canvasHeight;
|
| 58 |
+
|
| 59 |
+
if (newWidth && newWidth !== canvasWidth) setCanvasWidth(newWidth);
|
| 60 |
+
if (newHeight && newHeight !== canvasHeight) setCanvasHeight(newHeight);
|
| 61 |
+
|
| 62 |
+
setHistoryStack(prevStack => {
|
| 63 |
+
// If currentHistoryIndex is not at the end, it means we've undone some steps.
|
| 64 |
+
// New drawing should overwrite the "redo" history.
|
| 65 |
+
const newStackBase = prevStack.slice(0, currentHistoryIndex + 1);
|
| 66 |
+
let newStack = [...newStackBase, dataURL];
|
| 67 |
+
|
| 68 |
+
if (newStack.length > MAX_HISTORY_STEPS) {
|
| 69 |
+
newStack = newStack.slice(newStack.length - MAX_HISTORY_STEPS);
|
| 70 |
+
}
|
| 71 |
+
// Update currentHistoryIndex to point to the new state (end of the new stack)
|
| 72 |
+
setCurrentHistoryIndex(newStack.length - 1);
|
| 73 |
+
return newStack;
|
| 74 |
+
});
|
| 75 |
+
dbSaveCanvasState(dataURL, effectiveWidth, effectiveHeight);
|
| 76 |
+
}, [currentHistoryIndex, canvasWidth, canvasHeight]); // Added canvasWidth, canvasHeight as they are used for effective dimensions.
|
| 77 |
+
|
| 78 |
+
const updateCanvasState = useCallback((dataURL: string, width: number, height: number) => {
|
| 79 |
+
// This function is for direct updates, like loading an image or AI result
|
| 80 |
+
handleDrawEnd(dataURL, width, height);
|
| 81 |
+
}, [handleDrawEnd]);
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
const handleUndo = () => {
|
| 85 |
+
if (currentHistoryIndex > 0) {
|
| 86 |
+
setCurrentHistoryIndex(prevIndex => prevIndex - 1);
|
| 87 |
+
// Autosave current state when undoing to ensure persistence of the "undone to" state.
|
| 88 |
+
// This is implicit as currentDataURL will update, and if the app were to reload,
|
| 89 |
+
// it should load the state at currentHistoryIndex.
|
| 90 |
+
// The currentDataURL used by CanvasComponent will be historyStack[newIndex].
|
| 91 |
+
// dbSaveCanvasState(historyStack[currentHistoryIndex - 1], canvasWidth, canvasHeight); // This might be too aggressive, rely on drawEnd
|
| 92 |
+
}
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const handleRedo = () => {
|
| 96 |
+
if (currentHistoryIndex < historyStack.length - 1) {
|
| 97 |
+
setCurrentHistoryIndex(prevIndex => prevIndex + 1);
|
| 98 |
+
// dbSaveCanvasState(historyStack[currentHistoryIndex + 1], canvasWidth, canvasHeight); // Same as undo, might be too aggressive
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
historyStack,
|
| 104 |
+
currentHistoryIndex,
|
| 105 |
+
canvasWidth,
|
| 106 |
+
canvasHeight,
|
| 107 |
+
isLoading,
|
| 108 |
+
currentDataURL,
|
| 109 |
+
handleDrawEnd,
|
| 110 |
+
handleUndo,
|
| 111 |
+
handleRedo,
|
| 112 |
+
updateCanvasState,
|
| 113 |
+
};
|
| 114 |
+
};
|
hooks/useConfirmModal.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
export interface ConfirmModalInfo {
|
| 4 |
+
isOpen: boolean;
|
| 5 |
+
title: string;
|
| 6 |
+
message: string | React.ReactNode;
|
| 7 |
+
onConfirmAction: () => void;
|
| 8 |
+
isDestructive?: boolean;
|
| 9 |
+
confirmText?: string;
|
| 10 |
+
cancelText?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const initialConfirmModalState: ConfirmModalInfo = {
|
| 14 |
+
isOpen: false,
|
| 15 |
+
title: '',
|
| 16 |
+
message: '',
|
| 17 |
+
onConfirmAction: () => {},
|
| 18 |
+
isDestructive: false,
|
| 19 |
+
confirmText: 'Confirm',
|
| 20 |
+
cancelText: 'Cancel',
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export interface ConfirmModalHook {
|
| 24 |
+
confirmModalInfo: ConfirmModalInfo;
|
| 25 |
+
requestConfirmation: (
|
| 26 |
+
title: string,
|
| 27 |
+
message: string | React.ReactNode,
|
| 28 |
+
onConfirm: () => void,
|
| 29 |
+
options?: {
|
| 30 |
+
isDestructive?: boolean;
|
| 31 |
+
confirmText?: string;
|
| 32 |
+
cancelText?: string;
|
| 33 |
+
}
|
| 34 |
+
) => void;
|
| 35 |
+
closeConfirmModal: () => void;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export const useConfirmModal = (): ConfirmModalHook => {
|
| 39 |
+
const [confirmModalInfo, setConfirmModalInfo] = useState<ConfirmModalInfo>(initialConfirmModalState);
|
| 40 |
+
|
| 41 |
+
const requestConfirmation = useCallback(
|
| 42 |
+
(
|
| 43 |
+
title: string,
|
| 44 |
+
message: string | React.ReactNode,
|
| 45 |
+
onConfirm: () => void,
|
| 46 |
+
options: {
|
| 47 |
+
isDestructive?: boolean;
|
| 48 |
+
confirmText?: string;
|
| 49 |
+
cancelText?: string;
|
| 50 |
+
} = {}
|
| 51 |
+
) => {
|
| 52 |
+
setConfirmModalInfo({
|
| 53 |
+
isOpen: true,
|
| 54 |
+
title,
|
| 55 |
+
message,
|
| 56 |
+
onConfirmAction: () => {
|
| 57 |
+
onConfirm();
|
| 58 |
+
setConfirmModalInfo(prev => ({ ...prev, isOpen: false })); // Close modal after action
|
| 59 |
+
},
|
| 60 |
+
isDestructive: options.isDestructive ?? false,
|
| 61 |
+
confirmText: options.confirmText ?? 'Confirm',
|
| 62 |
+
cancelText: options.cancelText ?? 'Cancel',
|
| 63 |
+
});
|
| 64 |
+
},
|
| 65 |
+
[]
|
| 66 |
+
);
|
| 67 |
+
|
| 68 |
+
const closeConfirmModal = useCallback(() => {
|
| 69 |
+
setConfirmModalInfo(prev => ({ ...prev, isOpen: false }));
|
| 70 |
+
}, []);
|
| 71 |
+
|
| 72 |
+
return { confirmModalInfo, requestConfirmation, closeConfirmModal };
|
| 73 |
+
};
|
hooks/useDrawingTools.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { DEFAULT_PEN_COLOR, DEFAULT_PEN_SIZE } from '../constants';
|
| 3 |
+
|
| 4 |
+
export interface DrawingToolsHook {
|
| 5 |
+
penColor: string;
|
| 6 |
+
setPenColor: React.Dispatch<React.SetStateAction<string>>;
|
| 7 |
+
penSize: number;
|
| 8 |
+
setPenSize: React.Dispatch<React.SetStateAction<number>>;
|
| 9 |
+
isEraserMode: boolean;
|
| 10 |
+
setIsEraserMode: React.Dispatch<React.SetStateAction<boolean>>;
|
| 11 |
+
toggleEraserMode: () => void;
|
| 12 |
+
effectivePenColor: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const useDrawingTools = (): DrawingToolsHook => {
|
| 16 |
+
const [penColor, setPenColor] = useState<string>(DEFAULT_PEN_COLOR);
|
| 17 |
+
const [penSize, setPenSize] = useState<number>(DEFAULT_PEN_SIZE);
|
| 18 |
+
const [isEraserMode, setIsEraserMode] = useState<boolean>(false);
|
| 19 |
+
|
| 20 |
+
const toggleEraserMode = () => setIsEraserMode(prev => !prev);
|
| 21 |
+
|
| 22 |
+
const effectivePenColor = isEraserMode ? '#FFFFFF' : penColor; // Eraser uses white to clear
|
| 23 |
+
|
| 24 |
+
return {
|
| 25 |
+
penColor,
|
| 26 |
+
setPenColor,
|
| 27 |
+
penSize,
|
| 28 |
+
setPenSize,
|
| 29 |
+
isEraserMode,
|
| 30 |
+
setIsEraserMode,
|
| 31 |
+
toggleEraserMode,
|
| 32 |
+
effectivePenColor,
|
| 33 |
+
};
|
| 34 |
+
};
|
hooks/useFullscreen.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
export interface FullscreenHook {
|
| 4 |
+
isFullscreenActive: boolean;
|
| 5 |
+
toggleFullscreen: () => Promise<void>;
|
| 6 |
+
requestFullscreenSupportError?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const useFullscreen = (
|
| 10 |
+
showToast?: (message: string, type: 'info' | 'error') => void
|
| 11 |
+
): FullscreenHook => {
|
| 12 |
+
const [isFullscreenActive, setIsFullscreenActive] = useState<boolean>(false);
|
| 13 |
+
const [requestFullscreenSupportError, setRequestFullscreenSupportError] = useState<string | undefined>();
|
| 14 |
+
|
| 15 |
+
const handleFullscreenChange = useCallback(() => {
|
| 16 |
+
setIsFullscreenActive(!!(
|
| 17 |
+
document.fullscreenElement ||
|
| 18 |
+
(document as any).webkitFullscreenElement ||
|
| 19 |
+
(document as any).mozFullScreenElement ||
|
| 20 |
+
(document as any).msFullscreenElement
|
| 21 |
+
));
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
| 26 |
+
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
| 27 |
+
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
| 28 |
+
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
| 29 |
+
|
| 30 |
+
handleFullscreenChange(); // Initial check
|
| 31 |
+
|
| 32 |
+
return () => {
|
| 33 |
+
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
| 34 |
+
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
| 35 |
+
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
|
| 36 |
+
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
|
| 37 |
+
};
|
| 38 |
+
}, [handleFullscreenChange]);
|
| 39 |
+
|
| 40 |
+
const toggleFullscreen = async () => {
|
| 41 |
+
const element = document.documentElement as any;
|
| 42 |
+
try {
|
| 43 |
+
if (!isFullscreenActive) {
|
| 44 |
+
if (element.requestFullscreen) {
|
| 45 |
+
await element.requestFullscreen();
|
| 46 |
+
} else if (element.mozRequestFullScreen) { // Firefox
|
| 47 |
+
await element.mozRequestFullScreen();
|
| 48 |
+
} else if (element.webkitRequestFullscreen) { // Chrome, Safari, Opera
|
| 49 |
+
await element.webkitRequestFullscreen();
|
| 50 |
+
} else if (element.msRequestFullscreen) { // IE/Edge
|
| 51 |
+
await element.msRequestFullscreen();
|
| 52 |
+
} else {
|
| 53 |
+
const errorMsg = "Fullscreen API is not supported by this browser.";
|
| 54 |
+
setRequestFullscreenSupportError(errorMsg);
|
| 55 |
+
if (showToast) showToast(errorMsg, "error");
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
} else {
|
| 59 |
+
if (document.exitFullscreen) {
|
| 60 |
+
await document.exitFullscreen();
|
| 61 |
+
} else if ((document as any).mozCancelFullScreen) {
|
| 62 |
+
await (document as any).mozCancelFullScreen();
|
| 63 |
+
} else if ((document as any).webkitExitFullscreen) {
|
| 64 |
+
await (document as any).webkitExitFullscreen();
|
| 65 |
+
} else if ((document as any).msExitFullscreen) {
|
| 66 |
+
await (document as any).msExitFullscreen();
|
| 67 |
+
} else {
|
| 68 |
+
const errorMsg = "Could not exit fullscreen. API not found.";
|
| 69 |
+
setRequestFullscreenSupportError(errorMsg);
|
| 70 |
+
if (showToast) showToast(errorMsg, "error");
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
setRequestFullscreenSupportError(undefined);
|
| 75 |
+
} catch (err: any) {
|
| 76 |
+
console.error("Fullscreen API error:", err);
|
| 77 |
+
const errorMsg = `Fullscreen mode change failed: ${err.message || 'Unknown error'}`;
|
| 78 |
+
setRequestFullscreenSupportError(errorMsg);
|
| 79 |
+
if (showToast) showToast(errorMsg, "error");
|
| 80 |
+
handleFullscreenChange(); // Ensure state reflects reality
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
return { isFullscreenActive, toggleFullscreen, requestFullscreenSupportError };
|
| 85 |
+
};
|
hooks/useToasts.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { useState, useCallback } from 'react';
|
| 3 |
+
|
| 4 |
+
export type ToastMessage = {
|
| 5 |
+
id: string;
|
| 6 |
+
message: string;
|
| 7 |
+
type: 'success' | 'error' | 'info';
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export const useToasts = () => {
|
| 11 |
+
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
| 12 |
+
|
| 13 |
+
const showToast = useCallback((message: string, type: ToastMessage['type']) => {
|
| 14 |
+
const newToast: ToastMessage = { id: Date.now().toString(), message, type };
|
| 15 |
+
// Add new toast and limit to max 5 toasts shown
|
| 16 |
+
setToasts(prevToasts => [newToast, ...prevToasts.slice(0, 4)]);
|
| 17 |
+
setTimeout(() => {
|
| 18 |
+
setToasts(prevToasts => prevToasts.filter(t => t.id !== newToast.id));
|
| 19 |
+
}, 5000); // Autohide after 5 seconds
|
| 20 |
+
}, []);
|
| 21 |
+
|
| 22 |
+
return { toasts, showToast };
|
| 23 |
+
};
|
index.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>React Paint App</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script type="importmap">
|
| 9 |
+
{
|
| 10 |
+
"imports": {
|
| 11 |
+
"react/": "https://esm.sh/react@^19.1.0/",
|
| 12 |
+
"react": "https://esm.sh/react@^19.1.0",
|
| 13 |
+
"react-dom/": "https://esm.sh/react-dom@^19.1.0/"
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
</script>
|
| 17 |
+
<link rel="stylesheet" href="/index.css">
|
| 18 |
+
</head>
|
| 19 |
+
<body class="bg-gray-100">
|
| 20 |
+
<div id="root"></div>
|
| 21 |
+
<script type="module" src="/index.tsx"></script>
|
| 22 |
+
</body>
|
| 23 |
+
</html>
|
index.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
|
| 5 |
+
const rootElement = document.getElementById('root');
|
| 6 |
+
if (!rootElement) {
|
| 7 |
+
throw new Error("Could not find root element to mount to");
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const root = ReactDOM.createRoot(rootElement);
|
| 11 |
+
root.render(
|
| 12 |
+
<React.StrictMode>
|
| 13 |
+
<App />
|
| 14 |
+
</React.StrictMode>
|
| 15 |
+
);
|
metadata.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "xpaintai",
|
| 3 |
+
"description": "A simple web-based paint application with features like pen customization, undo/redo, image loading/exporting, and autosave to IndexedDB.",
|
| 4 |
+
"requestFramePermissions": [],
|
| 5 |
+
"prompt": ""
|
| 6 |
+
}
|
package.json
CHANGED
|
@@ -1,39 +1,20 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
-
"
|
| 6 |
-
|
| 7 |
-
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
-
"@testing-library/react": "^16.3.0",
|
| 9 |
-
"@testing-library/user-event": "^13.5.0",
|
| 10 |
-
"react": "^19.1.0",
|
| 11 |
-
"react-dom": "^19.1.0",
|
| 12 |
-
"react-scripts": "5.0.1",
|
| 13 |
-
"web-vitals": "^2.1.4"
|
| 14 |
-
},
|
| 15 |
"scripts": {
|
| 16 |
-
"
|
| 17 |
-
"build": "
|
| 18 |
-
"
|
| 19 |
-
"eject": "react-scripts eject"
|
| 20 |
},
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
"react-app/jest"
|
| 25 |
-
]
|
| 26 |
},
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"not op_mini all"
|
| 32 |
-
],
|
| 33 |
-
"development": [
|
| 34 |
-
"last 1 chrome version",
|
| 35 |
-
"last 1 firefox version",
|
| 36 |
-
"last 1 safari version"
|
| 37 |
-
]
|
| 38 |
}
|
| 39 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "xpaintai",
|
|
|
|
| 3 |
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
|
|
|
| 10 |
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^19.1.0",
|
| 13 |
+
"react-dom": "^19.1.0"
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@types/node": "^22.14.0",
|
| 17 |
+
"typescript": "~5.7.2",
|
| 18 |
+
"vite": "^6.2.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
}
|
services/dbService.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { DB_NAME, CANVAS_STORE_NAME, CANVAS_AUTOSAVE_KEY, DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT } from '../constants';
|
| 2 |
+
|
| 3 |
+
const DB_VERSION = 1; // Keep version, onupgradeneeded handles store creation
|
| 4 |
+
|
| 5 |
+
interface CanvasSave {
|
| 6 |
+
id: string;
|
| 7 |
+
dataURL: string;
|
| 8 |
+
width: number;
|
| 9 |
+
height: number;
|
| 10 |
+
timestamp: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export interface LoadedCanvasState {
|
| 14 |
+
dataURL: string;
|
| 15 |
+
width: number;
|
| 16 |
+
height: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const openDB = (): Promise<IDBDatabase> => {
|
| 20 |
+
return new Promise((resolve, reject) => {
|
| 21 |
+
if (!window.indexedDB) {
|
| 22 |
+
console.warn("IndexedDB not supported by this browser.");
|
| 23 |
+
reject(new Error("IndexedDB not supported."));
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
| 27 |
+
|
| 28 |
+
request.onerror = () => reject(new Error('Failed to open IndexedDB: ' + request.error?.message));
|
| 29 |
+
request.onsuccess = () => resolve(request.result);
|
| 30 |
+
|
| 31 |
+
request.onupgradeneeded = (event) => {
|
| 32 |
+
const db = (event.target as IDBOpenDBRequest).result;
|
| 33 |
+
if (!db.objectStoreNames.contains(CANVAS_STORE_NAME)) {
|
| 34 |
+
db.createObjectStore(CANVAS_STORE_NAME, { keyPath: 'id' });
|
| 35 |
+
}
|
| 36 |
+
// No need to alter existing stores if only adding properties to stored objects.
|
| 37 |
+
// If keyPath or indexes changed, would need more complex migration.
|
| 38 |
+
};
|
| 39 |
+
});
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
export const saveCanvasState = async (dataURL: string, width: number, height: number): Promise<void> => {
|
| 43 |
+
try {
|
| 44 |
+
const db = await openDB();
|
| 45 |
+
const transaction = db.transaction(CANVAS_STORE_NAME, 'readwrite');
|
| 46 |
+
const store = transaction.objectStore(CANVAS_STORE_NAME);
|
| 47 |
+
const canvasData: CanvasSave = {
|
| 48 |
+
id: CANVAS_AUTOSAVE_KEY,
|
| 49 |
+
dataURL,
|
| 50 |
+
width,
|
| 51 |
+
height,
|
| 52 |
+
timestamp: Date.now(),
|
| 53 |
+
};
|
| 54 |
+
store.put(canvasData);
|
| 55 |
+
|
| 56 |
+
return new Promise((resolve, reject) => {
|
| 57 |
+
transaction.oncomplete = () => resolve();
|
| 58 |
+
transaction.onerror = () => {
|
| 59 |
+
console.error('Transaction error saving canvas state:', transaction.error);
|
| 60 |
+
reject(new Error('Failed to save canvas state.'));
|
| 61 |
+
}
|
| 62 |
+
});
|
| 63 |
+
} catch (error) {
|
| 64 |
+
console.error('Error saving canvas state to IndexedDB:', error);
|
| 65 |
+
// Gracefully fail, app can still function
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
export const loadCanvasState = async (): Promise<LoadedCanvasState | null> => {
|
| 70 |
+
try {
|
| 71 |
+
const db = await openDB();
|
| 72 |
+
const transaction = db.transaction(CANVAS_STORE_NAME, 'readonly');
|
| 73 |
+
const store = transaction.objectStore(CANVAS_STORE_NAME);
|
| 74 |
+
const request = store.get(CANVAS_AUTOSAVE_KEY);
|
| 75 |
+
|
| 76 |
+
return new Promise((resolve, reject) => {
|
| 77 |
+
request.onsuccess = () => {
|
| 78 |
+
if (request.result) {
|
| 79 |
+
const savedData = request.result as CanvasSave;
|
| 80 |
+
resolve({
|
| 81 |
+
dataURL: savedData.dataURL,
|
| 82 |
+
// Provide default dimensions if old data doesn't have them
|
| 83 |
+
width: savedData.width || DEFAULT_CANVAS_WIDTH,
|
| 84 |
+
height: savedData.height || DEFAULT_CANVAS_HEIGHT,
|
| 85 |
+
});
|
| 86 |
+
} else {
|
| 87 |
+
resolve(null);
|
| 88 |
+
}
|
| 89 |
+
};
|
| 90 |
+
request.onerror = () => {
|
| 91 |
+
console.error('Request error loading canvas state:', request.error);
|
| 92 |
+
reject(new Error('Failed to load canvas state.'));
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
} catch (error) {
|
| 96 |
+
console.error('Error loading canvas state from IndexedDB:', error);
|
| 97 |
+
return null; // Gracefully fail
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
export const clearCanvasStateDB = async (): Promise<void> => {
|
| 102 |
+
try {
|
| 103 |
+
const db = await openDB();
|
| 104 |
+
const transaction = db.transaction(CANVAS_STORE_NAME, 'readwrite');
|
| 105 |
+
const store = transaction.objectStore(CANVAS_STORE_NAME);
|
| 106 |
+
const request = store.delete(CANVAS_AUTOSAVE_KEY);
|
| 107 |
+
|
| 108 |
+
return new Promise((resolve, reject) => {
|
| 109 |
+
transaction.oncomplete = () => resolve();
|
| 110 |
+
transaction.onerror = () => {
|
| 111 |
+
console.error('Transaction error clearing canvas state:', transaction.error);
|
| 112 |
+
reject(new Error('Failed to clear canvas state from DB.'));
|
| 113 |
+
}
|
| 114 |
+
request.onerror = () => { // Also handle request error for delete specifically
|
| 115 |
+
console.error('Request error deleting canvas state:', request.error);
|
| 116 |
+
reject(new Error('Failed to delete canvas state from DB.'));
|
| 117 |
+
};
|
| 118 |
+
});
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.error('Error clearing canvas state from IndexedDB:', error);
|
| 121 |
+
}
|
| 122 |
+
};
|
tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"experimentalDecorators": true,
|
| 5 |
+
"useDefineForClassFields": false,
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
"allowJs": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
|
| 19 |
+
/* Linting */
|
| 20 |
+
"strict": true,
|
| 21 |
+
"noUnusedLocals": true,
|
| 22 |
+
"noUnusedParameters": true,
|
| 23 |
+
"noFallthroughCasesInSwitch": true,
|
| 24 |
+
"noUncheckedSideEffectImports": true,
|
| 25 |
+
|
| 26 |
+
"paths": {
|
| 27 |
+
"@/*" : ["./*"]
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
types.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// No global shared complex types needed for now.
|
| 2 |
+
// Specific types/interfaces are defined within components or services where they are used.
|
| 3 |
+
// Example: export interface Point { x: number; y: number; } if it were broadly used.
|
utils/canvasUtils.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
export const getBlankCanvasDataURL = (width: number, height: number): string => {
|
| 3 |
+
const tempCanvas = document.createElement('canvas');
|
| 4 |
+
tempCanvas.width = width;
|
| 5 |
+
tempCanvas.height = height;
|
| 6 |
+
const ctx = tempCanvas.getContext('2d');
|
| 7 |
+
if (ctx) {
|
| 8 |
+
ctx.fillStyle = '#FFFFFF'; // Ensure blank canvas is white
|
| 9 |
+
ctx.fillRect(0, 0, width, height);
|
| 10 |
+
}
|
| 11 |
+
return tempCanvas.toDataURL('image/png');
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export const dataURLtoBlob = async (dataurl: string): Promise<Blob> => {
|
| 15 |
+
const res = await fetch(dataurl);
|
| 16 |
+
return res.blob();
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export const calculateSHA256 = async (blob: Blob): Promise<string> => {
|
| 20 |
+
const buffer = await blob.arrayBuffer();
|
| 21 |
+
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
| 22 |
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
| 23 |
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export const copyToClipboard = async (
|
| 27 |
+
text: string,
|
| 28 |
+
showInfoToast: (message: string, type: 'info' | 'error') => void // Callback for toast
|
| 29 |
+
): Promise<boolean> => {
|
| 30 |
+
if (!navigator.clipboard) {
|
| 31 |
+
showInfoToast('Clipboard API not available.', 'info');
|
| 32 |
+
return false;
|
| 33 |
+
}
|
| 34 |
+
try {
|
| 35 |
+
await navigator.clipboard.writeText(text);
|
| 36 |
+
return true;
|
| 37 |
+
} catch (err) {
|
| 38 |
+
console.error('Failed to copy text: ', err);
|
| 39 |
+
showInfoToast('Failed to copy URL to clipboard.', 'error');
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
};
|
vite.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'path';
|
| 2 |
+
import { defineConfig, loadEnv } from 'vite';
|
| 3 |
+
|
| 4 |
+
export default defineConfig(({ mode }) => {
|
| 5 |
+
const env = loadEnv(mode, '.', '');
|
| 6 |
+
return {
|
| 7 |
+
define: {
|
| 8 |
+
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
| 9 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
| 10 |
+
},
|
| 11 |
+
resolve: {
|
| 12 |
+
alias: {
|
| 13 |
+
'@': path.resolve(__dirname, '.'),
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
});
|