suisuyy commited on
Commit
763be49
·
1 Parent(s): 722075a

Initialize xpaintai project with core files and basic structure

Browse files
.gitignore CHANGED
@@ -1,23 +1,24 @@
1
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
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 &copy; {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
- # Getting Started with Create React App
15
 
16
- This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
17
 
18
- ## Available Scripts
19
 
20
- In the project directory, you can run:
21
 
22
- ### `npm start`
23
-
24
- Runs the app in the development mode.\
25
- Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
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": "react-template",
3
- "version": "0.1.0",
4
  "private": true,
5
- "dependencies": {
6
- "@testing-library/dom": "^10.4.0",
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
- "start": "react-scripts start",
17
- "build": "react-scripts build",
18
- "test": "react-scripts test",
19
- "eject": "react-scripts eject"
20
  },
21
- "eslintConfig": {
22
- "extends": [
23
- "react-app",
24
- "react-app/jest"
25
- ]
26
  },
27
- "browserslist": {
28
- "production": [
29
- ">0.2%",
30
- "not dead",
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
+ });