suisuyy commited on
Commit ·
69ae4e4
1
Parent(s): 8769c74
Rename project to xpaintai_zh in package.json and metadata.json; integrate translation support in various components and hooks
Browse files- App.tsx +17 -6
- components/AiEditModal.tsx +12 -9
- components/CanvasComponent.tsx +4 -1
- components/SettingsPanel.tsx +31 -28
- components/Toolbar.tsx +29 -26
- components/ZoomSlider.tsx +6 -3
- hooks/useAiFeatures.ts +28 -25
- hooks/useCanvasFileUtils.ts +16 -13
- hooks/useConfirmModal.ts +5 -4
- hooks/useFullscreen.ts +6 -4
- metadata.json +1 -1
- package.json +1 -1
- translations.ts +260 -0
- types.ts +3 -0
- utils/canvasUtils.ts +6 -3
App.tsx
CHANGED
|
@@ -6,6 +6,8 @@ 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';
|
|
@@ -16,6 +18,8 @@ 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);
|
|
@@ -53,8 +57,8 @@ const App: React.FC = () => {
|
|
| 53 |
effectivePenColor,
|
| 54 |
} = useDrawingTools();
|
| 55 |
|
| 56 |
-
const { isFullscreenActive, toggleFullscreen } = useFullscreen(showToast);
|
| 57 |
-
const { confirmModalInfo, requestConfirmation, closeConfirmModal } = useConfirmModal();
|
| 58 |
|
| 59 |
const {
|
| 60 |
handleLoadImageFile,
|
|
@@ -66,6 +70,7 @@ const App: React.FC = () => {
|
|
| 66 |
historyActions: { updateCanvasState },
|
| 67 |
uiActions: { showToast, setZoomLevel },
|
| 68 |
confirmModalActions: { requestConfirmation },
|
|
|
|
| 69 |
});
|
| 70 |
|
| 71 |
const {
|
|
@@ -93,6 +98,7 @@ const App: React.FC = () => {
|
|
| 93 |
aiDimensionsMode,
|
| 94 |
currentCanvasWidth: canvasWidth, // Pass current canvas dimensions
|
| 95 |
currentCanvasHeight: canvasHeight, // Pass current canvas dimensions
|
|
|
|
| 96 |
});
|
| 97 |
|
| 98 |
const handlePromptChange = (newPrompt: string) => {
|
|
@@ -116,8 +122,8 @@ const App: React.FC = () => {
|
|
| 116 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 117 |
<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>
|
| 118 |
</svg>
|
| 119 |
-
<p className="text-2xl font-semibold text-slate-700">
|
| 120 |
-
<p className="text-sm text-slate-500 mt-1">
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
);
|
|
@@ -144,6 +150,7 @@ const App: React.FC = () => {
|
|
| 144 |
isMagicUploading={isMagicUploading}
|
| 145 |
canShare={canShare}
|
| 146 |
onToggleSettings={handleToggleSettingsPanel}
|
|
|
|
| 147 |
/>
|
| 148 |
<main className="flex-grow grid place-items-center p-4 md:p-6 w-full mt-4 mb-4 overflow-auto">
|
| 149 |
<div
|
|
@@ -152,7 +159,7 @@ const App: React.FC = () => {
|
|
| 152 |
transformOrigin: 'top left',
|
| 153 |
transition: 'transform 0.1s ease-out'
|
| 154 |
}}
|
| 155 |
-
aria-label={
|
| 156 |
>
|
| 157 |
{currentDataURL ? (
|
| 158 |
<CanvasComponent
|
|
@@ -165,13 +172,14 @@ const App: React.FC = () => {
|
|
| 165 |
canvasPhysicalWidth={canvasWidth}
|
| 166 |
canvasPhysicalHeight={canvasHeight}
|
| 167 |
zoomLevel={zoomLevel}
|
|
|
|
| 168 |
/>
|
| 169 |
) : (
|
| 170 |
<div
|
| 171 |
style={{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }}
|
| 172 |
className="border-2 border-slate-300 rounded-lg shadow-xl bg-white flex justify-center items-center"
|
| 173 |
>
|
| 174 |
-
<p className="text-slate-500">
|
| 175 |
</div>
|
| 176 |
)}
|
| 177 |
</div>
|
|
@@ -190,6 +198,7 @@ const App: React.FC = () => {
|
|
| 190 |
isAsking={isAskingAi}
|
| 191 |
error={aiEditError}
|
| 192 |
askUrl={askUrl}
|
|
|
|
| 193 |
/>
|
| 194 |
)}
|
| 195 |
|
|
@@ -197,6 +206,7 @@ const App: React.FC = () => {
|
|
| 197 |
<ZoomSlider
|
| 198 |
zoomLevel={zoomLevel}
|
| 199 |
onZoomChange={setZoomLevel}
|
|
|
|
| 200 |
/>
|
| 201 |
)}
|
| 202 |
|
|
@@ -217,6 +227,7 @@ const App: React.FC = () => {
|
|
| 217 |
currentCanvasWidth={canvasWidth}
|
| 218 |
currentCanvasHeight={canvasHeight}
|
| 219 |
onCanvasSizeChange={handleCanvasSizeChange}
|
|
|
|
| 220 |
/>
|
| 221 |
)}
|
| 222 |
|
|
|
|
| 6 |
import SettingsPanel from './components/SettingsPanel';
|
| 7 |
import ConfirmModal from './components/ConfirmModal';
|
| 8 |
import { DEFAULT_AI_API_ENDPOINT } from './constants';
|
| 9 |
+
import { getTranslator } from './translations';
|
| 10 |
+
import { TFunction } from './types';
|
| 11 |
|
| 12 |
// Custom Hooks
|
| 13 |
import { useToasts } from './hooks/useToasts';
|
|
|
|
| 18 |
import { useConfirmModal } from './hooks/useConfirmModal';
|
| 19 |
import { useCanvasFileUtils } from './hooks/useCanvasFileUtils';
|
| 20 |
|
| 21 |
+
const t: TFunction = getTranslator();
|
| 22 |
+
|
| 23 |
const App: React.FC = () => {
|
| 24 |
// Core UI States
|
| 25 |
const [zoomLevel, setZoomLevel] = useState<number>(0.5);
|
|
|
|
| 57 |
effectivePenColor,
|
| 58 |
} = useDrawingTools();
|
| 59 |
|
| 60 |
+
const { isFullscreenActive, toggleFullscreen } = useFullscreen(showToast, t);
|
| 61 |
+
const { confirmModalInfo, requestConfirmation, closeConfirmModal } = useConfirmModal(t);
|
| 62 |
|
| 63 |
const {
|
| 64 |
handleLoadImageFile,
|
|
|
|
| 70 |
historyActions: { updateCanvasState },
|
| 71 |
uiActions: { showToast, setZoomLevel },
|
| 72 |
confirmModalActions: { requestConfirmation },
|
| 73 |
+
t,
|
| 74 |
});
|
| 75 |
|
| 76 |
const {
|
|
|
|
| 98 |
aiDimensionsMode,
|
| 99 |
currentCanvasWidth: canvasWidth, // Pass current canvas dimensions
|
| 100 |
currentCanvasHeight: canvasHeight, // Pass current canvas dimensions
|
| 101 |
+
t,
|
| 102 |
});
|
| 103 |
|
| 104 |
const handlePromptChange = (newPrompt: string) => {
|
|
|
|
| 122 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 123 |
<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>
|
| 124 |
</svg>
|
| 125 |
+
<p className="text-2xl font-semibold text-slate-700">{t('loadingCanvasMessage')}</p>
|
| 126 |
+
<p className="text-sm text-slate-500 mt-1">{t('loadingCanvasSubMessage')}</p>
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
);
|
|
|
|
| 150 |
isMagicUploading={isMagicUploading}
|
| 151 |
canShare={canShare}
|
| 152 |
onToggleSettings={handleToggleSettingsPanel}
|
| 153 |
+
t={t}
|
| 154 |
/>
|
| 155 |
<main className="flex-grow grid place-items-center p-4 md:p-6 w-full mt-4 mb-4 overflow-auto">
|
| 156 |
<div
|
|
|
|
| 159 |
transformOrigin: 'top left',
|
| 160 |
transition: 'transform 0.1s ease-out'
|
| 161 |
}}
|
| 162 |
+
aria-label={t('zoomAria', { percentage: Math.round(zoomLevel * 100) })}
|
| 163 |
>
|
| 164 |
{currentDataURL ? (
|
| 165 |
<CanvasComponent
|
|
|
|
| 172 |
canvasPhysicalWidth={canvasWidth}
|
| 173 |
canvasPhysicalHeight={canvasHeight}
|
| 174 |
zoomLevel={zoomLevel}
|
| 175 |
+
t={t}
|
| 176 |
/>
|
| 177 |
) : (
|
| 178 |
<div
|
| 179 |
style={{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }}
|
| 180 |
className="border-2 border-slate-300 rounded-lg shadow-xl bg-white flex justify-center items-center"
|
| 181 |
>
|
| 182 |
+
<p className="text-slate-500">{t('initializingCanvas')}</p>
|
| 183 |
</div>
|
| 184 |
)}
|
| 185 |
</div>
|
|
|
|
| 198 |
isAsking={isAskingAi}
|
| 199 |
error={aiEditError}
|
| 200 |
askUrl={askUrl}
|
| 201 |
+
t={t}
|
| 202 |
/>
|
| 203 |
)}
|
| 204 |
|
|
|
|
| 206 |
<ZoomSlider
|
| 207 |
zoomLevel={zoomLevel}
|
| 208 |
onZoomChange={setZoomLevel}
|
| 209 |
+
t={t}
|
| 210 |
/>
|
| 211 |
)}
|
| 212 |
|
|
|
|
| 227 |
currentCanvasWidth={canvasWidth}
|
| 228 |
currentCanvasHeight={canvasHeight}
|
| 229 |
onCanvasSizeChange={handleCanvasSizeChange}
|
| 230 |
+
t={t}
|
| 231 |
/>
|
| 232 |
)}
|
| 233 |
|
components/AiEditModal.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import React, { useEffect, useRef } from 'react';
|
| 2 |
import { MagicSparkleIcon, CloseIcon, QuestionMarkIcon } from './icons';
|
|
|
|
| 3 |
|
| 4 |
interface AiEditModalProps {
|
| 5 |
isOpen: boolean;
|
|
@@ -13,6 +14,7 @@ interface AiEditModalProps {
|
|
| 13 |
isAsking: boolean;
|
| 14 |
error: string | null;
|
| 15 |
askUrl: string | null;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
@@ -27,6 +29,7 @@ const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
| 27 |
isAsking,
|
| 28 |
error,
|
| 29 |
askUrl,
|
|
|
|
| 30 |
}) => {
|
| 31 |
const iframeContainerRef = useRef<HTMLDivElement>(null);
|
| 32 |
|
|
@@ -49,11 +52,11 @@ const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
| 49 |
>
|
| 50 |
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-lg transform transition-all max-h-[calc(100vh-5rem)] overflow-y-auto">
|
| 51 |
<div className="flex justify-between items-center mb-4">
|
| 52 |
-
<h2 id="ai-edit-modal-title" className="text-xl font-semibold text-slate-700">
|
| 53 |
<button
|
| 54 |
onClick={onClose}
|
| 55 |
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 56 |
-
aria-label=
|
| 57 |
disabled={isAnyLoading}
|
| 58 |
>
|
| 59 |
<CloseIcon className="w-6 h-6" />
|
|
@@ -70,13 +73,13 @@ const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
| 70 |
|
| 71 |
<div className="mb-4">
|
| 72 |
<label htmlFor="aiPrompt" className="block text-sm font-medium text-slate-700 mb-1">
|
| 73 |
-
|
| 74 |
</label>
|
| 75 |
<textarea
|
| 76 |
id="aiPrompt"
|
| 77 |
value={prompt}
|
| 78 |
onChange={(e) => onPromptChange(e.target.value)}
|
| 79 |
-
placeholder=
|
| 80 |
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-xl h-[200px]"
|
| 81 |
disabled={isAnyLoading}
|
| 82 |
aria-describedby={error ? "ai-edit-error" : undefined}
|
|
@@ -85,7 +88,7 @@ const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
| 85 |
|
| 86 |
{error && (
|
| 87 |
<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">
|
| 88 |
-
<p className="font-semibold">
|
| 89 |
<p>{error}</p>
|
| 90 |
</div>
|
| 91 |
)}
|
|
@@ -102,12 +105,12 @@ const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
| 102 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 103 |
<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>
|
| 104 |
</svg>
|
| 105 |
-
|
| 106 |
</>
|
| 107 |
) : (
|
| 108 |
<>
|
| 109 |
<QuestionMarkIcon className="w-4 h-4 mr-1" />
|
| 110 |
-
|
| 111 |
</>
|
| 112 |
)}
|
| 113 |
</button>
|
|
@@ -122,12 +125,12 @@ const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
| 122 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 123 |
<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>
|
| 124 |
</svg>
|
| 125 |
-
|
| 126 |
</>
|
| 127 |
) : (
|
| 128 |
<>
|
| 129 |
<MagicSparkleIcon className="w-4 h-4 mr-1" />
|
| 130 |
-
|
| 131 |
</>
|
| 132 |
)}
|
| 133 |
</button>
|
|
|
|
| 1 |
import React, { useEffect, useRef } from 'react';
|
| 2 |
import { MagicSparkleIcon, CloseIcon, QuestionMarkIcon } from './icons';
|
| 3 |
+
import { TFunction } from '../types';
|
| 4 |
|
| 5 |
interface AiEditModalProps {
|
| 6 |
isOpen: boolean;
|
|
|
|
| 14 |
isAsking: boolean;
|
| 15 |
error: string | null;
|
| 16 |
askUrl: string | null;
|
| 17 |
+
t: TFunction;
|
| 18 |
}
|
| 19 |
|
| 20 |
const AiEditModal: React.FC<AiEditModalProps> = ({
|
|
|
|
| 29 |
isAsking,
|
| 30 |
error,
|
| 31 |
askUrl,
|
| 32 |
+
t,
|
| 33 |
}) => {
|
| 34 |
const iframeContainerRef = useRef<HTMLDivElement>(null);
|
| 35 |
|
|
|
|
| 52 |
>
|
| 53 |
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-lg transform transition-all max-h-[calc(100vh-5rem)] overflow-y-auto">
|
| 54 |
<div className="flex justify-between items-center mb-4">
|
| 55 |
+
<h2 id="ai-edit-modal-title" className="text-xl font-semibold text-slate-700">{t('aiModalTitle')}</h2>
|
| 56 |
<button
|
| 57 |
onClick={onClose}
|
| 58 |
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 59 |
+
aria-label={t('closeAiModalAria')}
|
| 60 |
disabled={isAnyLoading}
|
| 61 |
>
|
| 62 |
<CloseIcon className="w-6 h-6" />
|
|
|
|
| 73 |
|
| 74 |
<div className="mb-4">
|
| 75 |
<label htmlFor="aiPrompt" className="block text-sm font-medium text-slate-700 mb-1">
|
| 76 |
+
{t('aiPromptLabel')}
|
| 77 |
</label>
|
| 78 |
<textarea
|
| 79 |
id="aiPrompt"
|
| 80 |
value={prompt}
|
| 81 |
onChange={(e) => onPromptChange(e.target.value)}
|
| 82 |
+
placeholder={t('aiPromptPlaceholder')}
|
| 83 |
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-xl h-[200px]"
|
| 84 |
disabled={isAnyLoading}
|
| 85 |
aria-describedby={error ? "ai-edit-error" : undefined}
|
|
|
|
| 88 |
|
| 89 |
{error && (
|
| 90 |
<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">
|
| 91 |
+
<p className="font-semibold">{t('errorPrefix')}</p>
|
| 92 |
<p>{error}</p>
|
| 93 |
</div>
|
| 94 |
)}
|
|
|
|
| 105 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 106 |
<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>
|
| 107 |
</svg>
|
| 108 |
+
{t('aiAsking')}
|
| 109 |
</>
|
| 110 |
) : (
|
| 111 |
<>
|
| 112 |
<QuestionMarkIcon className="w-4 h-4 mr-1" />
|
| 113 |
+
{t('aiAsk')}
|
| 114 |
</>
|
| 115 |
)}
|
| 116 |
</button>
|
|
|
|
| 125 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 126 |
<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>
|
| 127 |
</svg>
|
| 128 |
+
{t('aiGenerating')}
|
| 129 |
</>
|
| 130 |
) : (
|
| 131 |
<>
|
| 132 |
<MagicSparkleIcon className="w-4 h-4 mr-1" />
|
| 133 |
+
{t('aiGenerateImage')}
|
| 134 |
</>
|
| 135 |
)}
|
| 136 |
</button>
|
components/CanvasComponent.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|
|
|
| 2 |
|
| 3 |
interface CanvasComponentProps {
|
| 4 |
penColor: string;
|
|
@@ -9,6 +10,7 @@ interface CanvasComponentProps {
|
|
| 9 |
canvasPhysicalWidth: number;
|
| 10 |
canvasPhysicalHeight: number;
|
| 11 |
zoomLevel: number; // New prop
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const CanvasComponent: React.FC<CanvasComponentProps> = ({
|
|
@@ -20,6 +22,7 @@ const CanvasComponent: React.FC<CanvasComponentProps> = ({
|
|
| 20 |
canvasPhysicalWidth,
|
| 21 |
canvasPhysicalHeight,
|
| 22 |
zoomLevel, // Use zoomLevel
|
|
|
|
| 23 |
}) => {
|
| 24 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 25 |
const [isDrawing, setIsDrawing] = useState(false);
|
|
@@ -194,7 +197,7 @@ const CanvasComponent: React.FC<CanvasComponentProps> = ({
|
|
| 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=
|
| 198 |
/>
|
| 199 |
);
|
| 200 |
};
|
|
|
|
| 1 |
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { TFunction } from '../types';
|
| 3 |
|
| 4 |
interface CanvasComponentProps {
|
| 5 |
penColor: string;
|
|
|
|
| 10 |
canvasPhysicalWidth: number;
|
| 11 |
canvasPhysicalHeight: number;
|
| 12 |
zoomLevel: number; // New prop
|
| 13 |
+
t: TFunction;
|
| 14 |
}
|
| 15 |
|
| 16 |
const CanvasComponent: React.FC<CanvasComponentProps> = ({
|
|
|
|
| 22 |
canvasPhysicalWidth,
|
| 23 |
canvasPhysicalHeight,
|
| 24 |
zoomLevel, // Use zoomLevel
|
| 25 |
+
t,
|
| 26 |
}) => {
|
| 27 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 28 |
const [isDrawing, setIsDrawing] = useState(false);
|
|
|
|
| 197 |
// Style for imageRendering is good for pixel art, ensure it doesn't conflict with scaling needs.
|
| 198 |
// Visual scaling is handled by the parent div's transform.
|
| 199 |
style={{ imageRendering: 'pixelated' }}
|
| 200 |
+
aria-label={t('drawingCanvas')}
|
| 201 |
/>
|
| 202 |
);
|
| 203 |
};
|
components/SettingsPanel.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { CloseIcon, FullscreenEnterIcon, FullscreenExitIcon } from './icons'; // Import fullscreen icons
|
| 3 |
import { AiImageQuality, AiDimensionsMode } from '../hooks/useAiFeatures';
|
|
|
|
| 4 |
|
| 5 |
interface SettingsPanelProps {
|
| 6 |
isOpen: boolean;
|
|
@@ -18,6 +19,7 @@ interface SettingsPanelProps {
|
|
| 18 |
currentCanvasWidth: number;
|
| 19 |
currentCanvasHeight: number;
|
| 20 |
onCanvasSizeChange: (width: number, height: number) => void;
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
const CANVAS_SIZE_OPTIONS = [
|
|
@@ -31,14 +33,14 @@ const CANVAS_SIZE_OPTIONS = [
|
|
| 31 |
|
| 32 |
const AI_API_OPTIONS = [
|
| 33 |
{
|
| 34 |
-
|
| 35 |
value: 'https://rpfor.deno.dev/proxy?url=https://image.pollinations.ai/prompt/{prompt}?nologo=true&model=gptimage&image={imgurl.url}&quality={quality}{size_params}'
|
| 36 |
},
|
| 37 |
{
|
| 38 |
-
|
| 39 |
value: 'https://geminisimpleget.deno.dev/prompt/{prompt}?image={imgurl.url}'
|
| 40 |
}
|
| 41 |
-
];
|
| 42 |
|
| 43 |
|
| 44 |
const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
@@ -57,13 +59,14 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 57 |
currentCanvasWidth,
|
| 58 |
currentCanvasHeight,
|
| 59 |
onCanvasSizeChange,
|
|
|
|
| 60 |
}) => {
|
| 61 |
if (!isOpen) return null;
|
| 62 |
|
| 63 |
const qualityOptions: { label: string; value: AiImageQuality }[] = [
|
| 64 |
-
{ label: '
|
| 65 |
-
{ label: '
|
| 66 |
-
{ label:
|
| 67 |
];
|
| 68 |
|
| 69 |
const handleSizeSelection = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
@@ -86,12 +89,12 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 86 |
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-md transform transition-all overflow-y-auto max-h-[90vh]">
|
| 87 |
<div className="flex justify-between items-center mb-6">
|
| 88 |
<h2 id="settings-panel-title" className="text-xl font-semibold text-slate-800">
|
| 89 |
-
|
| 90 |
</h2>
|
| 91 |
<button
|
| 92 |
onClick={onClose}
|
| 93 |
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 94 |
-
aria-label=
|
| 95 |
>
|
| 96 |
<CloseIcon className="w-6 h-6" />
|
| 97 |
</button>
|
|
@@ -99,12 +102,12 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 99 |
|
| 100 |
{/* Interface Settings */}
|
| 101 |
<div className="mb-6">
|
| 102 |
-
<h3 className="text-md font-medium text-slate-700 mb-2">
|
| 103 |
<div className="space-y-3">
|
| 104 |
{/* Zoom Slider Toggle */}
|
| 105 |
<div className="flex items-center justify-between bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 106 |
<label htmlFor="showZoomSliderToggle" className="text-sm text-slate-600">
|
| 107 |
-
|
| 108 |
</label>
|
| 109 |
<button
|
| 110 |
id="showZoomSliderToggle"
|
|
@@ -115,7 +118,7 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 115 |
showZoomSlider ? 'bg-blue-600' : 'bg-slate-300'
|
| 116 |
}`}
|
| 117 |
>
|
| 118 |
-
<span className="sr-only">
|
| 119 |
<span
|
| 120 |
className={`inline-block w-4 h-4 transform bg-white rounded-full transition-transform duration-200 ease-in-out ${
|
| 121 |
showZoomSlider ? 'translate-x-6' : 'translate-x-1'
|
|
@@ -129,14 +132,14 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 129 |
<button
|
| 130 |
onClick={onToggleFullscreen}
|
| 131 |
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"
|
| 132 |
-
aria-label={isFullscreenActive ? '
|
| 133 |
>
|
| 134 |
{isFullscreenActive ? (
|
| 135 |
<FullscreenExitIcon className="w-5 h-5 mr-2" />
|
| 136 |
) : (
|
| 137 |
<FullscreenEnterIcon className="w-5 h-5 mr-2" />
|
| 138 |
)}
|
| 139 |
-
{isFullscreenActive ? '
|
| 140 |
</button>
|
| 141 |
</div>
|
| 142 |
</div>
|
|
@@ -144,10 +147,10 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 144 |
|
| 145 |
{/* Canvas Dimensions Settings */}
|
| 146 |
<div className="mb-6">
|
| 147 |
-
<h3 className="text-md font-medium text-slate-700 mb-2">
|
| 148 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 149 |
<label htmlFor="canvasSizeSelect" className="block text-sm text-slate-600 mb-1">
|
| 150 |
-
|
| 151 |
</label>
|
| 152 |
<select
|
| 153 |
id="canvasSizeSelect"
|
|
@@ -163,7 +166,7 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 163 |
))}
|
| 164 |
</select>
|
| 165 |
<p id="canvas-size-description" className="text-xs text-slate-500 mt-1">
|
| 166 |
-
|
| 167 |
</p>
|
| 168 |
</div>
|
| 169 |
</div>
|
|
@@ -171,11 +174,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 171 |
|
| 172 |
{/* AI Image Generation Settings */}
|
| 173 |
<div className="mb-6">
|
| 174 |
-
<h3 className="text-md font-medium text-slate-700 mb-2">
|
| 175 |
|
| 176 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200 mb-4">
|
| 177 |
<label htmlFor="aiApiEndpointSelect" className="block text-sm text-slate-600 mb-1">
|
| 178 |
-
|
| 179 |
</label>
|
| 180 |
<select
|
| 181 |
id="aiApiEndpointSelect"
|
|
@@ -186,18 +189,18 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 186 |
>
|
| 187 |
{AI_API_OPTIONS.map((option) => (
|
| 188 |
<option key={option.value} value={option.value}>
|
| 189 |
-
{option.
|
| 190 |
</option>
|
| 191 |
))}
|
| 192 |
</select>
|
| 193 |
<p id="ai-api-description" className="text-xs text-slate-500 mt-1">
|
| 194 |
-
|
| 195 |
</p>
|
| 196 |
</div>
|
| 197 |
|
| 198 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200 mb-4">
|
| 199 |
<label htmlFor="aiDimensionsModeSelect" className="block text-sm text-slate-600 mb-1">
|
| 200 |
-
|
| 201 |
</label>
|
| 202 |
<select
|
| 203 |
id="aiDimensionsModeSelect"
|
|
@@ -206,18 +209,18 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 206 |
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"
|
| 207 |
aria-describedby="ai-dimensions-description"
|
| 208 |
>
|
| 209 |
-
<option value="api_default">
|
| 210 |
-
<option value="match_canvas">
|
| 211 |
-
<option value="fixed_1024">
|
| 212 |
</select>
|
| 213 |
<p id="ai-dimensions-description" className="text-xs text-slate-500 mt-1">
|
| 214 |
-
|
| 215 |
</p>
|
| 216 |
</div>
|
| 217 |
|
| 218 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 219 |
<label htmlFor="aiImageQualitySelect" className="block text-sm text-slate-600 mb-1">
|
| 220 |
-
|
| 221 |
</label>
|
| 222 |
<select
|
| 223 |
id="aiImageQualitySelect"
|
|
@@ -233,7 +236,7 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 233 |
))}
|
| 234 |
</select>
|
| 235 |
<p id="ai-quality-description" className="text-xs text-slate-500 mt-1">
|
| 236 |
-
|
| 237 |
</p>
|
| 238 |
</div>
|
| 239 |
</div>
|
|
@@ -243,7 +246,7 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
| 243 |
onClick={onClose}
|
| 244 |
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"
|
| 245 |
>
|
| 246 |
-
|
| 247 |
</button>
|
| 248 |
</div>
|
| 249 |
</div>
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { CloseIcon, FullscreenEnterIcon, FullscreenExitIcon } from './icons'; // Import fullscreen icons
|
| 3 |
import { AiImageQuality, AiDimensionsMode } from '../hooks/useAiFeatures';
|
| 4 |
+
import { TFunction } from '../types';
|
| 5 |
|
| 6 |
interface SettingsPanelProps {
|
| 7 |
isOpen: boolean;
|
|
|
|
| 19 |
currentCanvasWidth: number;
|
| 20 |
currentCanvasHeight: number;
|
| 21 |
onCanvasSizeChange: (width: number, height: number) => void;
|
| 22 |
+
t: TFunction;
|
| 23 |
}
|
| 24 |
|
| 25 |
const CANVAS_SIZE_OPTIONS = [
|
|
|
|
| 33 |
|
| 34 |
const AI_API_OPTIONS = [
|
| 35 |
{
|
| 36 |
+
labelKey: 'pollinationsApi',
|
| 37 |
value: 'https://rpfor.deno.dev/proxy?url=https://image.pollinations.ai/prompt/{prompt}?nologo=true&model=gptimage&image={imgurl.url}&quality={quality}{size_params}'
|
| 38 |
},
|
| 39 |
{
|
| 40 |
+
labelKey: 'geminiSimpleGetApi',
|
| 41 |
value: 'https://geminisimpleget.deno.dev/prompt/{prompt}?image={imgurl.url}'
|
| 42 |
}
|
| 43 |
+
] as const;
|
| 44 |
|
| 45 |
|
| 46 |
const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|
|
|
| 59 |
currentCanvasWidth,
|
| 60 |
currentCanvasHeight,
|
| 61 |
onCanvasSizeChange,
|
| 62 |
+
t,
|
| 63 |
}) => {
|
| 64 |
if (!isOpen) return null;
|
| 65 |
|
| 66 |
const qualityOptions: { label: string; value: AiImageQuality }[] = [
|
| 67 |
+
{ label: t('qualityLow'), value: 'low' },
|
| 68 |
+
{ label: t('qualityMedium'), value: 'medium' },
|
| 69 |
+
{ label: t('qualityHigh'), value: 'hd' },
|
| 70 |
];
|
| 71 |
|
| 72 |
const handleSizeSelection = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
|
|
| 89 |
<div className="bg-white rounded-lg shadow-2xl p-6 w-full max-w-md transform transition-all overflow-y-auto max-h-[90vh]">
|
| 90 |
<div className="flex justify-between items-center mb-6">
|
| 91 |
<h2 id="settings-panel-title" className="text-xl font-semibold text-slate-800">
|
| 92 |
+
{t('settingsTitle')}
|
| 93 |
</h2>
|
| 94 |
<button
|
| 95 |
onClick={onClose}
|
| 96 |
className="text-slate-400 hover:text-slate-600 transition-colors"
|
| 97 |
+
aria-label={t('closeSettingsAria')}
|
| 98 |
>
|
| 99 |
<CloseIcon className="w-6 h-6" />
|
| 100 |
</button>
|
|
|
|
| 102 |
|
| 103 |
{/* Interface Settings */}
|
| 104 |
<div className="mb-6">
|
| 105 |
+
<h3 className="text-md font-medium text-slate-700 mb-2">{t('interface')}</h3>
|
| 106 |
<div className="space-y-3">
|
| 107 |
{/* Zoom Slider Toggle */}
|
| 108 |
<div className="flex items-center justify-between bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 109 |
<label htmlFor="showZoomSliderToggle" className="text-sm text-slate-600">
|
| 110 |
+
{t('showZoomSlider')}
|
| 111 |
</label>
|
| 112 |
<button
|
| 113 |
id="showZoomSliderToggle"
|
|
|
|
| 118 |
showZoomSlider ? 'bg-blue-600' : 'bg-slate-300'
|
| 119 |
}`}
|
| 120 |
>
|
| 121 |
+
<span className="sr-only">{t('toggleZoomSliderAria')}</span>
|
| 122 |
<span
|
| 123 |
className={`inline-block w-4 h-4 transform bg-white rounded-full transition-transform duration-200 ease-in-out ${
|
| 124 |
showZoomSlider ? 'translate-x-6' : 'translate-x-1'
|
|
|
|
| 132 |
<button
|
| 133 |
onClick={onToggleFullscreen}
|
| 134 |
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"
|
| 135 |
+
aria-label={isFullscreenActive ? t('exitFullscreenAria') : t('fullscreenAria')}
|
| 136 |
>
|
| 137 |
{isFullscreenActive ? (
|
| 138 |
<FullscreenExitIcon className="w-5 h-5 mr-2" />
|
| 139 |
) : (
|
| 140 |
<FullscreenEnterIcon className="w-5 h-5 mr-2" />
|
| 141 |
)}
|
| 142 |
+
{isFullscreenActive ? t('exitFullscreen') : t('enterFullscreen')}
|
| 143 |
</button>
|
| 144 |
</div>
|
| 145 |
</div>
|
|
|
|
| 147 |
|
| 148 |
{/* Canvas Dimensions Settings */}
|
| 149 |
<div className="mb-6">
|
| 150 |
+
<h3 className="text-md font-medium text-slate-700 mb-2">{t('canvasDimensions')}</h3>
|
| 151 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 152 |
<label htmlFor="canvasSizeSelect" className="block text-sm text-slate-600 mb-1">
|
| 153 |
+
{t('canvasSize')}
|
| 154 |
</label>
|
| 155 |
<select
|
| 156 |
id="canvasSizeSelect"
|
|
|
|
| 166 |
))}
|
| 167 |
</select>
|
| 168 |
<p id="canvas-size-description" className="text-xs text-slate-500 mt-1">
|
| 169 |
+
{t('canvasSizeDescription')}
|
| 170 |
</p>
|
| 171 |
</div>
|
| 172 |
</div>
|
|
|
|
| 174 |
|
| 175 |
{/* AI Image Generation Settings */}
|
| 176 |
<div className="mb-6">
|
| 177 |
+
<h3 className="text-md font-medium text-slate-700 mb-2">{t('aiImageGeneration')}</h3>
|
| 178 |
|
| 179 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200 mb-4">
|
| 180 |
<label htmlFor="aiApiEndpointSelect" className="block text-sm text-slate-600 mb-1">
|
| 181 |
+
{t('apiEndpoint')}
|
| 182 |
</label>
|
| 183 |
<select
|
| 184 |
id="aiApiEndpointSelect"
|
|
|
|
| 189 |
>
|
| 190 |
{AI_API_OPTIONS.map((option) => (
|
| 191 |
<option key={option.value} value={option.value}>
|
| 192 |
+
{t(option.labelKey)}
|
| 193 |
</option>
|
| 194 |
))}
|
| 195 |
</select>
|
| 196 |
<p id="ai-api-description" className="text-xs text-slate-500 mt-1">
|
| 197 |
+
{t('apiEndpointDescription')}
|
| 198 |
</p>
|
| 199 |
</div>
|
| 200 |
|
| 201 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200 mb-4">
|
| 202 |
<label htmlFor="aiDimensionsModeSelect" className="block text-sm text-slate-600 mb-1">
|
| 203 |
+
{t('aiImageDimensions')}
|
| 204 |
</label>
|
| 205 |
<select
|
| 206 |
id="aiDimensionsModeSelect"
|
|
|
|
| 209 |
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"
|
| 210 |
aria-describedby="ai-dimensions-description"
|
| 211 |
>
|
| 212 |
+
<option value="api_default">{t('apiDefault')}</option>
|
| 213 |
+
<option value="match_canvas">{t('matchCanvasSize')}</option>
|
| 214 |
+
<option value="fixed_1024">{t('fixed1024')}</option>
|
| 215 |
</select>
|
| 216 |
<p id="ai-dimensions-description" className="text-xs text-slate-500 mt-1">
|
| 217 |
+
{t('aiImageDimensionsDescription')}
|
| 218 |
</p>
|
| 219 |
</div>
|
| 220 |
|
| 221 |
<div className="bg-slate-50 p-3 rounded-md border border-slate-200">
|
| 222 |
<label htmlFor="aiImageQualitySelect" className="block text-sm text-slate-600 mb-1">
|
| 223 |
+
{t('imageQuality')}
|
| 224 |
</label>
|
| 225 |
<select
|
| 226 |
id="aiImageQualitySelect"
|
|
|
|
| 236 |
))}
|
| 237 |
</select>
|
| 238 |
<p id="ai-quality-description" className="text-xs text-slate-500 mt-1">
|
| 239 |
+
{t('imageQualityDescription')}
|
| 240 |
</p>
|
| 241 |
</div>
|
| 242 |
</div>
|
|
|
|
| 246 |
onClick={onClose}
|
| 247 |
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"
|
| 248 |
>
|
| 249 |
+
{t('done')}
|
| 250 |
</button>
|
| 251 |
</div>
|
| 252 |
</div>
|
components/Toolbar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 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;
|
|
@@ -18,7 +19,8 @@ interface ToolbarProps {
|
|
| 18 |
onMagicUpload: () => void;
|
| 19 |
isMagicUploading: boolean;
|
| 20 |
canShare: boolean;
|
| 21 |
-
onToggleSettings: () => void;
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
const Toolbar: React.FC<ToolbarProps> = ({
|
|
@@ -38,7 +40,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 38 |
onMagicUpload,
|
| 39 |
isMagicUploading,
|
| 40 |
canShare,
|
| 41 |
-
onToggleSettings,
|
|
|
|
| 42 |
}) => {
|
| 43 |
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
| 44 |
|
|
@@ -53,9 +56,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 53 |
<button
|
| 54 |
onClick={onUndo}
|
| 55 |
disabled={!canUndo}
|
| 56 |
-
title=
|
| 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=
|
| 59 |
>
|
| 60 |
<UndoIcon className="w-5 h-5 text-gray-700" />
|
| 61 |
</button>
|
|
@@ -63,9 +66,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 63 |
<button
|
| 64 |
onClick={onRedo}
|
| 65 |
disabled={!canRedo}
|
| 66 |
-
title=
|
| 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=
|
| 69 |
>
|
| 70 |
<RedoIcon className="w-5 h-5 text-gray-700" />
|
| 71 |
</button>
|
|
@@ -76,41 +79,41 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 76 |
onChange={onLoadImage}
|
| 77 |
accept="image/png, image/jpeg"
|
| 78 |
className="hidden"
|
| 79 |
-
aria-label=
|
| 80 |
/>
|
| 81 |
<button
|
| 82 |
onClick={handleUploadClick}
|
| 83 |
-
title=
|
| 84 |
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 85 |
-
aria-label=
|
| 86 |
>
|
| 87 |
<UploadIcon className="w-5 h-5 text-gray-700" />
|
| 88 |
</button>
|
| 89 |
|
| 90 |
<button
|
| 91 |
onClick={onExportImage}
|
| 92 |
-
title=
|
| 93 |
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 94 |
-
aria-label=
|
| 95 |
>
|
| 96 |
<DownloadIcon className="w-5 h-5 text-gray-700" />
|
| 97 |
</button>
|
| 98 |
|
| 99 |
<button
|
| 100 |
onClick={onMagicUpload}
|
| 101 |
-
title=
|
| 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=
|
| 105 |
>
|
| 106 |
<MagicSparkleIcon className="w-5 h-5 text-purple-600" />
|
| 107 |
</button>
|
| 108 |
|
| 109 |
<button
|
| 110 |
onClick={onClearCanvas}
|
| 111 |
-
title=
|
| 112 |
className="p-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors tool-button"
|
| 113 |
-
aria-label=
|
| 114 |
>
|
| 115 |
<ClearIcon className="w-5 h-5" />
|
| 116 |
</button>
|
|
@@ -120,40 +123,40 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 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 ?
|
| 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 ?
|
| 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">
|
| 133 |
<input
|
| 134 |
type="color"
|
| 135 |
id="penColor"
|
| 136 |
-
title=
|
| 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=
|
| 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">
|
| 147 |
<input
|
| 148 |
type="range"
|
| 149 |
id="penSize"
|
| 150 |
-
title={
|
| 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={
|
| 157 |
aria-valuemin={1}
|
| 158 |
aria-valuemax={50}
|
| 159 |
aria-valuenow={penSize}
|
|
@@ -163,9 +166,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 163 |
|
| 164 |
<button
|
| 165 |
onClick={onToggleSettings}
|
| 166 |
-
title=
|
| 167 |
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 168 |
-
aria-label=
|
| 169 |
>
|
| 170 |
<SettingsIcon className="w-5 h-5 text-gray-700" />
|
| 171 |
</button>
|
|
@@ -174,4 +177,4 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|
| 174 |
);
|
| 175 |
};
|
| 176 |
|
| 177 |
-
export default Toolbar;
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { UndoIcon, RedoIcon, UploadIcon, DownloadIcon, ClearIcon, BrushIcon, EraserIcon, MagicSparkleIcon, SettingsIcon } from './icons';
|
| 3 |
+
import { TFunction } from '../types';
|
| 4 |
|
| 5 |
interface ToolbarProps {
|
| 6 |
penColor: string;
|
|
|
|
| 19 |
onMagicUpload: () => void;
|
| 20 |
isMagicUploading: boolean;
|
| 21 |
canShare: boolean;
|
| 22 |
+
onToggleSettings: () => void;
|
| 23 |
+
t: TFunction;
|
| 24 |
}
|
| 25 |
|
| 26 |
const Toolbar: React.FC<ToolbarProps> = ({
|
|
|
|
| 40 |
onMagicUpload,
|
| 41 |
isMagicUploading,
|
| 42 |
canShare,
|
| 43 |
+
onToggleSettings,
|
| 44 |
+
t,
|
| 45 |
}) => {
|
| 46 |
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
| 47 |
|
|
|
|
| 56 |
<button
|
| 57 |
onClick={onUndo}
|
| 58 |
disabled={!canUndo}
|
| 59 |
+
title={t('undo')}
|
| 60 |
className="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-gray-300"
|
| 61 |
+
aria-label={t('undoAria')}
|
| 62 |
>
|
| 63 |
<UndoIcon className="w-5 h-5 text-gray-700" />
|
| 64 |
</button>
|
|
|
|
| 66 |
<button
|
| 67 |
onClick={onRedo}
|
| 68 |
disabled={!canRedo}
|
| 69 |
+
title={t('redo')}
|
| 70 |
className="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-gray-300"
|
| 71 |
+
aria-label={t('redoAria')}
|
| 72 |
>
|
| 73 |
<RedoIcon className="w-5 h-5 text-gray-700" />
|
| 74 |
</button>
|
|
|
|
| 79 |
onChange={onLoadImage}
|
| 80 |
accept="image/png, image/jpeg"
|
| 81 |
className="hidden"
|
| 82 |
+
aria-label={t('loadImageAria')}
|
| 83 |
/>
|
| 84 |
<button
|
| 85 |
onClick={handleUploadClick}
|
| 86 |
+
title={t('loadImage')}
|
| 87 |
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 88 |
+
aria-label={t('loadImageAria')}
|
| 89 |
>
|
| 90 |
<UploadIcon className="w-5 h-5 text-gray-700" />
|
| 91 |
</button>
|
| 92 |
|
| 93 |
<button
|
| 94 |
onClick={onExportImage}
|
| 95 |
+
title={t('exportImage')}
|
| 96 |
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 97 |
+
aria-label={t('exportImageAria')}
|
| 98 |
>
|
| 99 |
<DownloadIcon className="w-5 h-5 text-gray-700" />
|
| 100 |
</button>
|
| 101 |
|
| 102 |
<button
|
| 103 |
onClick={onMagicUpload}
|
| 104 |
+
title={t('shareAndEditWithAI')}
|
| 105 |
disabled={isMagicUploading || !canShare}
|
| 106 |
className="p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-gray-300"
|
| 107 |
+
aria-label={t('shareAndEditWithAIAria')}
|
| 108 |
>
|
| 109 |
<MagicSparkleIcon className="w-5 h-5 text-purple-600" />
|
| 110 |
</button>
|
| 111 |
|
| 112 |
<button
|
| 113 |
onClick={onClearCanvas}
|
| 114 |
+
title={t('clearCanvas')}
|
| 115 |
className="p-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors tool-button"
|
| 116 |
+
aria-label={t('clearCanvasAria')}
|
| 117 |
>
|
| 118 |
<ClearIcon className="w-5 h-5" />
|
| 119 |
</button>
|
|
|
|
| 123 |
<div className="flex flex-wrap items-center justify-start gap-x-3 gap-y-2 mt-2">
|
| 124 |
<button
|
| 125 |
onClick={onToggleEraser}
|
| 126 |
+
title={isEraserMode ? t('switchToBrush') : t('switchToEraser')}
|
| 127 |
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'}`}
|
| 128 |
+
aria-label={isEraserMode ? t('switchToBrushAria') : t('switchToEraserAria')}
|
| 129 |
aria-pressed={isEraserMode}
|
| 130 |
>
|
| 131 |
{isEraserMode ? <BrushIcon className="w-5 h-5" /> : <EraserIcon className="w-5 h-5" />}
|
| 132 |
</button>
|
| 133 |
|
| 134 |
<div className="flex items-center gap-2 p-1 rounded-md hover:bg-slate-300 transition-colors">
|
| 135 |
+
<label htmlFor="penColor" className="text-sm font-medium text-gray-700 sr-only">{t('penColor')}:</label>
|
| 136 |
<input
|
| 137 |
type="color"
|
| 138 |
id="penColor"
|
| 139 |
+
title={t('penColor')}
|
| 140 |
value={penColor}
|
| 141 |
onChange={(e) => onColorChange(e.target.value)}
|
| 142 |
className={`w-8 h-8 rounded-full cursor-pointer border-2 ${isEraserMode ? 'border-gray-400 opacity-50' : 'border-white shadow-sm'}`}
|
| 143 |
disabled={isEraserMode}
|
| 144 |
+
aria-label={t('selectPenColorAria')}
|
| 145 |
/>
|
| 146 |
</div>
|
| 147 |
|
| 148 |
<div className="flex items-center gap-2 p-1 rounded-md hover:bg-slate-300 transition-colors">
|
| 149 |
+
<label htmlFor="penSize" className="text-sm font-medium text-gray-700 sr-only">{t('penSize', { size: penSize })}:</label>
|
| 150 |
<input
|
| 151 |
type="range"
|
| 152 |
id="penSize"
|
| 153 |
+
title={t('penSize', { size: penSize })}
|
| 154 |
min="1"
|
| 155 |
max="50"
|
| 156 |
value={penSize}
|
| 157 |
onChange={(e) => onSizeChange(parseInt(e.target.value, 10))}
|
| 158 |
className="w-24 md:w-32 cursor-pointer accent-blue-600"
|
| 159 |
+
aria-label={t('penSizeAria', { size: penSize })}
|
| 160 |
aria-valuemin={1}
|
| 161 |
aria-valuemax={50}
|
| 162 |
aria-valuenow={penSize}
|
|
|
|
| 166 |
|
| 167 |
<button
|
| 168 |
onClick={onToggleSettings}
|
| 169 |
+
title={t('openSettings')}
|
| 170 |
className="p-2 rounded-md hover:bg-gray-100 transition-colors border-gray-300"
|
| 171 |
+
aria-label={t('openSettings')}
|
| 172 |
>
|
| 173 |
<SettingsIcon className="w-5 h-5 text-gray-700" />
|
| 174 |
</button>
|
|
|
|
| 177 |
);
|
| 178 |
};
|
| 179 |
|
| 180 |
+
export default Toolbar;
|
components/ZoomSlider.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
| 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 |
};
|
|
@@ -20,7 +23,7 @@ const ZoomSlider: React.FC<ZoomSliderProps> = ({ zoomLevel, onZoomChange }) => {
|
|
| 20 |
aria-label="Zoom controls"
|
| 21 |
>
|
| 22 |
<label htmlFor="zoom-slider" className="text-xs font-medium whitespace-nowrap">
|
| 23 |
-
|
| 24 |
</label>
|
| 25 |
<input
|
| 26 |
type="range"
|
|
@@ -34,7 +37,7 @@ const ZoomSlider: React.FC<ZoomSliderProps> = ({ zoomLevel, onZoomChange }) => {
|
|
| 34 |
aria-valuemin={20}
|
| 35 |
aria-valuemax={300}
|
| 36 |
aria-valuenow={zoomPercentage}
|
| 37 |
-
aria-label={
|
| 38 |
/>
|
| 39 |
</div>
|
| 40 |
);
|
|
|
|
| 1 |
|
| 2 |
+
|
| 3 |
import React from 'react';
|
| 4 |
+
import { TFunction } from '../types';
|
| 5 |
|
| 6 |
interface ZoomSliderProps {
|
| 7 |
zoomLevel: number;
|
| 8 |
onZoomChange: (newZoomLevel: number) => void;
|
| 9 |
+
t: TFunction;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
const ZoomSlider: React.FC<ZoomSliderProps> = ({ zoomLevel, onZoomChange, t }) => {
|
| 13 |
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 14 |
onZoomChange(parseFloat(event.target.value));
|
| 15 |
};
|
|
|
|
| 23 |
aria-label="Zoom controls"
|
| 24 |
>
|
| 25 |
<label htmlFor="zoom-slider" className="text-xs font-medium whitespace-nowrap">
|
| 26 |
+
{t('zoom', { percentage: zoomPercentage })}
|
| 27 |
</label>
|
| 28 |
<input
|
| 29 |
type="range"
|
|
|
|
| 37 |
aria-valuemin={20}
|
| 38 |
aria-valuemax={300}
|
| 39 |
aria-valuenow={zoomPercentage}
|
| 40 |
+
aria-label={t('zoomAria', { percentage: zoomPercentage })}
|
| 41 |
/>
|
| 42 |
</div>
|
| 43 |
);
|
hooks/useAiFeatures.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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 ASK_API_URL = 'https://getai.deno.dev/';
|
|
@@ -37,6 +38,7 @@ interface UseAiFeaturesProps {
|
|
| 37 |
aiDimensionsMode: AiDimensionsMode;
|
| 38 |
currentCanvasWidth: number;
|
| 39 |
currentCanvasHeight: number;
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
export const useAiFeatures = ({
|
|
@@ -48,7 +50,8 @@ export const useAiFeatures = ({
|
|
| 48 |
aiApiEndpoint,
|
| 49 |
aiDimensionsMode,
|
| 50 |
currentCanvasWidth,
|
| 51 |
-
currentCanvasHeight,
|
|
|
|
| 52 |
}: UseAiFeaturesProps): AiFeaturesHook => {
|
| 53 |
const [isMagicUploading, setIsMagicUploading] = useState<boolean>(false);
|
| 54 |
const [showAiEditModal, setShowAiEditModal] = useState<boolean>(false);
|
|
@@ -107,37 +110,37 @@ export const useAiFeatures = ({
|
|
| 107 |
updateCanvasState(newCanvasState, currentCanvasWidth, currentCanvasHeight);
|
| 108 |
|
| 109 |
setShowAiEditModal(false);
|
| 110 |
-
showToast('
|
| 111 |
setAiPrompt('');
|
| 112 |
setSharedImageUrlForAi(null);
|
| 113 |
} else {
|
| 114 |
-
setAiEditError('
|
| 115 |
}
|
| 116 |
setIsGeneratingAiImage(false);
|
| 117 |
};
|
| 118 |
img.onerror = () => {
|
| 119 |
-
setAiEditError('
|
| 120 |
setIsGeneratingAiImage(false);
|
| 121 |
};
|
| 122 |
img.crossOrigin = "anonymous";
|
| 123 |
img.src = aiImageDataUrl;
|
| 124 |
-
}, [showToast, updateCanvasState, currentCanvasWidth, currentCanvasHeight]);
|
| 125 |
|
| 126 |
const handleMagicUpload = useCallback(async () => {
|
| 127 |
if (isMagicUploading || !currentDataURL) {
|
| 128 |
-
showToast('
|
| 129 |
return;
|
| 130 |
}
|
| 131 |
|
| 132 |
setIsMagicUploading(true);
|
| 133 |
setAiEditError(null);
|
| 134 |
-
showToast('
|
| 135 |
|
| 136 |
try {
|
| 137 |
const blob = await dataURLtoBlob(currentDataURL);
|
| 138 |
|
| 139 |
if (blob.size > MAX_UPLOAD_SIZE_BYTES) {
|
| 140 |
-
showToast(
|
| 141 |
setIsMagicUploading(false);
|
| 142 |
return;
|
| 143 |
}
|
|
@@ -153,8 +156,8 @@ export const useAiFeatures = ({
|
|
| 153 |
});
|
| 154 |
|
| 155 |
if (!response.ok) {
|
| 156 |
-
let errorMsg =
|
| 157 |
-
try { const errorBody = await response.text(); errorMsg =
|
| 158 |
throw new Error(errorMsg);
|
| 159 |
}
|
| 160 |
|
|
@@ -169,21 +172,21 @@ export const useAiFeatures = ({
|
|
| 169 |
|
| 170 |
} catch (error: any) {
|
| 171 |
console.error('Magic upload error:', error);
|
| 172 |
-
showToast(error.message || '
|
| 173 |
} finally {
|
| 174 |
setIsMagicUploading(false);
|
| 175 |
}
|
| 176 |
-
}, [isMagicUploading, currentDataURL, showToast, clearAskUrl]);
|
| 177 |
|
| 178 |
const handleGenerateAiImage = useCallback(async () => {
|
| 179 |
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
|
| 180 |
-
setAiEditError('
|
| 181 |
return;
|
| 182 |
}
|
| 183 |
setIsGeneratingAiImage(true);
|
| 184 |
setAiEditError(null);
|
| 185 |
clearAskUrl();
|
| 186 |
-
showToast('
|
| 187 |
|
| 188 |
const encodedPrompt = encodeURIComponent(aiPrompt);
|
| 189 |
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
|
|
@@ -209,7 +212,7 @@ export const useAiFeatures = ({
|
|
| 209 |
try {
|
| 210 |
const response = await fetch(finalApiUrl);
|
| 211 |
if (!response.ok) {
|
| 212 |
-
let errorMsg =
|
| 213 |
try {
|
| 214 |
const errorBody = await response.text();
|
| 215 |
if (errorBody && !errorBody.toLowerCase().includes('<html')) {
|
|
@@ -221,7 +224,7 @@ export const useAiFeatures = ({
|
|
| 221 |
|
| 222 |
const imageBlob = await response.blob();
|
| 223 |
if (!imageBlob.type.startsWith('image/')) {
|
| 224 |
-
throw new Error('
|
| 225 |
}
|
| 226 |
|
| 227 |
const reader = new FileReader();
|
|
@@ -229,26 +232,26 @@ export const useAiFeatures = ({
|
|
| 229 |
if (typeof reader.result === 'string') {
|
| 230 |
loadAiImageOntoCanvas(reader.result);
|
| 231 |
} else {
|
| 232 |
-
setAiEditError('
|
| 233 |
setIsGeneratingAiImage(false);
|
| 234 |
}
|
| 235 |
};
|
| 236 |
reader.onerror = () => {
|
| 237 |
-
setAiEditError('
|
| 238 |
setIsGeneratingAiImage(false);
|
| 239 |
}
|
| 240 |
reader.readAsDataURL(imageBlob);
|
| 241 |
|
| 242 |
} catch (error: any) {
|
| 243 |
console.error('AI image generation error:', error);
|
| 244 |
-
setAiEditError(error.message || '
|
| 245 |
setIsGeneratingAiImage(false);
|
| 246 |
}
|
| 247 |
-
}, [aiPrompt, sharedImageUrlForAi, showToast, loadAiImageOntoCanvas, aiImageQuality, aiApiEndpoint, aiDimensionsMode, currentCanvasWidth, currentCanvasHeight, clearAskUrl]);
|
| 248 |
|
| 249 |
const handleAskAi = useCallback(async () => {
|
| 250 |
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
|
| 251 |
-
setAiEditError('
|
| 252 |
return;
|
| 253 |
}
|
| 254 |
setIsAskingAi(true);
|
|
@@ -263,19 +266,19 @@ export const useAiFeatures = ({
|
|
| 263 |
|
| 264 |
} catch (error: any) {
|
| 265 |
console.error('AI ask error:', error);
|
| 266 |
-
setAiEditError(error.message || '
|
| 267 |
} finally {
|
| 268 |
setIsAskingAi(false);
|
| 269 |
}
|
| 270 |
-
}, [aiPrompt, sharedImageUrlForAi]);
|
| 271 |
|
| 272 |
|
| 273 |
const handleCancelAiEdit = () => {
|
| 274 |
setShowAiEditModal(false);
|
| 275 |
if (sharedImageUrlForAi) {
|
| 276 |
-
copyToClipboard(sharedImageUrlForAi, (msg, type) => showToast(msg,type as 'info' | 'error')).then(copied => {
|
| 277 |
if(copied) {
|
| 278 |
-
showToast(
|
| 279 |
}
|
| 280 |
});
|
| 281 |
}
|
|
|
|
| 2 |
import { ToastMessage } from './useToasts';
|
| 3 |
import { dataURLtoBlob, calculateSHA256, copyToClipboard } from '../utils/canvasUtils';
|
| 4 |
import { CanvasHistoryHook } from './useCanvasHistory';
|
| 5 |
+
import { TFunction } from '../types';
|
| 6 |
|
| 7 |
const SHARE_API_URL = 'https://sharefile.suisuy.eu.org';
|
| 8 |
const ASK_API_URL = 'https://getai.deno.dev/';
|
|
|
|
| 38 |
aiDimensionsMode: AiDimensionsMode;
|
| 39 |
currentCanvasWidth: number;
|
| 40 |
currentCanvasHeight: number;
|
| 41 |
+
t: TFunction;
|
| 42 |
}
|
| 43 |
|
| 44 |
export const useAiFeatures = ({
|
|
|
|
| 50 |
aiApiEndpoint,
|
| 51 |
aiDimensionsMode,
|
| 52 |
currentCanvasWidth,
|
| 53 |
+
currentCanvasHeight,
|
| 54 |
+
t,
|
| 55 |
}: UseAiFeaturesProps): AiFeaturesHook => {
|
| 56 |
const [isMagicUploading, setIsMagicUploading] = useState<boolean>(false);
|
| 57 |
const [showAiEditModal, setShowAiEditModal] = useState<boolean>(false);
|
|
|
|
| 110 |
updateCanvasState(newCanvasState, currentCanvasWidth, currentCanvasHeight);
|
| 111 |
|
| 112 |
setShowAiEditModal(false);
|
| 113 |
+
showToast(t('aiImageApplied'), 'success');
|
| 114 |
setAiPrompt('');
|
| 115 |
setSharedImageUrlForAi(null);
|
| 116 |
} else {
|
| 117 |
+
setAiEditError(t('failedToCreateContext'));
|
| 118 |
}
|
| 119 |
setIsGeneratingAiImage(false);
|
| 120 |
};
|
| 121 |
img.onerror = () => {
|
| 122 |
+
setAiEditError(t('failedToLoadAiImage'));
|
| 123 |
setIsGeneratingAiImage(false);
|
| 124 |
};
|
| 125 |
img.crossOrigin = "anonymous";
|
| 126 |
img.src = aiImageDataUrl;
|
| 127 |
+
}, [showToast, updateCanvasState, currentCanvasWidth, currentCanvasHeight, t]);
|
| 128 |
|
| 129 |
const handleMagicUpload = useCallback(async () => {
|
| 130 |
if (isMagicUploading || !currentDataURL) {
|
| 131 |
+
showToast(t('noCanvasContentToShare'), 'info');
|
| 132 |
return;
|
| 133 |
}
|
| 134 |
|
| 135 |
setIsMagicUploading(true);
|
| 136 |
setAiEditError(null);
|
| 137 |
+
showToast(t('uploadingImage'), 'info');
|
| 138 |
|
| 139 |
try {
|
| 140 |
const blob = await dataURLtoBlob(currentDataURL);
|
| 141 |
|
| 142 |
if (blob.size > MAX_UPLOAD_SIZE_BYTES) {
|
| 143 |
+
showToast(t('imageTooLarge', { size: (blob.size / 1024 / 1024).toFixed(2) }), 'error');
|
| 144 |
setIsMagicUploading(false);
|
| 145 |
return;
|
| 146 |
}
|
|
|
|
| 156 |
});
|
| 157 |
|
| 158 |
if (!response.ok) {
|
| 159 |
+
let errorMsg = t('uploadFailed', { statusText: response.statusText });
|
| 160 |
+
try { const errorBody = await response.text(); errorMsg = t('uploadFailed', { statusText: errorBody || response.statusText }); } catch (e) { /* ignore */ }
|
| 161 |
throw new Error(errorMsg);
|
| 162 |
}
|
| 163 |
|
|
|
|
| 172 |
|
| 173 |
} catch (error: any) {
|
| 174 |
console.error('Magic upload error:', error);
|
| 175 |
+
showToast(error.message || t('magicUploadError'), 'error');
|
| 176 |
} finally {
|
| 177 |
setIsMagicUploading(false);
|
| 178 |
}
|
| 179 |
+
}, [isMagicUploading, currentDataURL, showToast, clearAskUrl, t]);
|
| 180 |
|
| 181 |
const handleGenerateAiImage = useCallback(async () => {
|
| 182 |
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
|
| 183 |
+
setAiEditError(t('enterPrompt'));
|
| 184 |
return;
|
| 185 |
}
|
| 186 |
setIsGeneratingAiImage(true);
|
| 187 |
setAiEditError(null);
|
| 188 |
clearAskUrl();
|
| 189 |
+
showToast(t('generatingAiImage'), 'info');
|
| 190 |
|
| 191 |
const encodedPrompt = encodeURIComponent(aiPrompt);
|
| 192 |
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
|
|
|
|
| 212 |
try {
|
| 213 |
const response = await fetch(finalApiUrl);
|
| 214 |
if (!response.ok) {
|
| 215 |
+
let errorMsg = t('aiGenFailed', { status: response.status, statusText: response.statusText });
|
| 216 |
try {
|
| 217 |
const errorBody = await response.text();
|
| 218 |
if (errorBody && !errorBody.toLowerCase().includes('<html')) {
|
|
|
|
| 224 |
|
| 225 |
const imageBlob = await response.blob();
|
| 226 |
if (!imageBlob.type.startsWith('image/')) {
|
| 227 |
+
throw new Error(t('aiGenInvalidImage'));
|
| 228 |
}
|
| 229 |
|
| 230 |
const reader = new FileReader();
|
|
|
|
| 232 |
if (typeof reader.result === 'string') {
|
| 233 |
loadAiImageOntoCanvas(reader.result);
|
| 234 |
} else {
|
| 235 |
+
setAiEditError(t('failedToReadAiData'));
|
| 236 |
setIsGeneratingAiImage(false);
|
| 237 |
}
|
| 238 |
};
|
| 239 |
reader.onerror = () => {
|
| 240 |
+
setAiEditError(t('failedToReadAiData'));
|
| 241 |
setIsGeneratingAiImage(false);
|
| 242 |
}
|
| 243 |
reader.readAsDataURL(imageBlob);
|
| 244 |
|
| 245 |
} catch (error: any) {
|
| 246 |
console.error('AI image generation error:', error);
|
| 247 |
+
setAiEditError(error.message || t('unknownAiError'));
|
| 248 |
setIsGeneratingAiImage(false);
|
| 249 |
}
|
| 250 |
+
}, [aiPrompt, sharedImageUrlForAi, showToast, loadAiImageOntoCanvas, aiImageQuality, aiApiEndpoint, aiDimensionsMode, currentCanvasWidth, currentCanvasHeight, clearAskUrl, t]);
|
| 251 |
|
| 252 |
const handleAskAi = useCallback(async () => {
|
| 253 |
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
|
| 254 |
+
setAiEditError(t('enterQuestion'));
|
| 255 |
return;
|
| 256 |
}
|
| 257 |
setIsAskingAi(true);
|
|
|
|
| 266 |
|
| 267 |
} catch (error: any) {
|
| 268 |
console.error('AI ask error:', error);
|
| 269 |
+
setAiEditError(error.message || t('unknownAskError'));
|
| 270 |
} finally {
|
| 271 |
setIsAskingAi(false);
|
| 272 |
}
|
| 273 |
+
}, [aiPrompt, sharedImageUrlForAi, t]);
|
| 274 |
|
| 275 |
|
| 276 |
const handleCancelAiEdit = () => {
|
| 277 |
setShowAiEditModal(false);
|
| 278 |
if (sharedImageUrlForAi) {
|
| 279 |
+
copyToClipboard(sharedImageUrlForAi, (msg, type) => showToast(msg,type as 'info' | 'error'), t).then(copied => {
|
| 280 |
if(copied) {
|
| 281 |
+
showToast(t('imageUploadedAndCopied', { url: sharedImageUrlForAi }), 'success');
|
| 282 |
}
|
| 283 |
});
|
| 284 |
}
|
hooks/useCanvasFileUtils.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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 {
|
|
@@ -21,6 +22,7 @@ interface UseCanvasFileUtilsProps {
|
|
| 21 |
confirmModalActions: {
|
| 22 |
requestConfirmation: ConfirmModalHook['requestConfirmation'];
|
| 23 |
};
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
export interface CanvasFileUtilsHook {
|
|
@@ -35,6 +37,7 @@ export const useCanvasFileUtils = ({
|
|
| 35 |
historyActions,
|
| 36 |
uiActions,
|
| 37 |
confirmModalActions,
|
|
|
|
| 38 |
}: UseCanvasFileUtilsProps): CanvasFileUtilsHook => {
|
| 39 |
const { currentDataURL, canvasWidth, canvasHeight } = canvasState;
|
| 40 |
const { updateCanvasState } = historyActions;
|
|
@@ -81,18 +84,18 @@ export const useCanvasFileUtils = ({
|
|
| 81 |
const compositeDataURL = tempCanvas.toDataURL('image/png');
|
| 82 |
|
| 83 |
updateCanvasState(compositeDataURL, finalCanvasWidth, finalCanvasHeight);
|
| 84 |
-
showToast('
|
| 85 |
}
|
| 86 |
};
|
| 87 |
-
img.onerror = () => showToast(
|
| 88 |
img.src = imageDataUrl;
|
| 89 |
}
|
| 90 |
};
|
| 91 |
-
reader.onerror = () => showToast(
|
| 92 |
reader.readAsDataURL(file);
|
| 93 |
if(event.target) event.target.value = '';
|
| 94 |
}
|
| 95 |
-
}, [canvasWidth, canvasHeight, updateCanvasState, showToast]);
|
| 96 |
|
| 97 |
const handleExportImage = useCallback(() => {
|
| 98 |
if (currentDataURL) {
|
|
@@ -102,34 +105,34 @@ export const useCanvasFileUtils = ({
|
|
| 102 |
document.body.appendChild(link);
|
| 103 |
link.click();
|
| 104 |
document.body.removeChild(link);
|
| 105 |
-
showToast('
|
| 106 |
} else {
|
| 107 |
-
showToast('
|
| 108 |
}
|
| 109 |
-
}, [currentDataURL, showToast]);
|
| 110 |
|
| 111 |
const handleClearCanvas = useCallback(() => {
|
| 112 |
const blankCanvas = getBlankCanvasDataURL(canvasWidth, canvasHeight);
|
| 113 |
updateCanvasState(blankCanvas, canvasWidth, canvasHeight);
|
| 114 |
-
showToast("
|
| 115 |
-
}, [canvasWidth, canvasHeight, updateCanvasState, showToast]);
|
| 116 |
|
| 117 |
const handleCanvasSizeChange = useCallback((newWidth: number, newHeight: number) => {
|
| 118 |
if (newWidth === canvasWidth && newHeight === canvasHeight) {
|
| 119 |
return;
|
| 120 |
}
|
| 121 |
requestConfirmation(
|
| 122 |
-
|
| 123 |
-
|
| 124 |
() => {
|
| 125 |
const blankCanvas = getBlankCanvasDataURL(newWidth, newHeight);
|
| 126 |
updateCanvasState(blankCanvas, newWidth, newHeight);
|
| 127 |
setZoomLevel(0.5);
|
| 128 |
-
showToast(
|
| 129 |
},
|
| 130 |
{ isDestructive: true }
|
| 131 |
);
|
| 132 |
-
}, [canvasWidth, canvasHeight, requestConfirmation, updateCanvasState, setZoomLevel, showToast]);
|
| 133 |
|
| 134 |
return { handleLoadImageFile, handleExportImage, handleClearCanvas, handleCanvasSizeChange };
|
| 135 |
};
|
|
|
|
| 3 |
import { CanvasHistoryHook } from './useCanvasHistory';
|
| 4 |
import { ConfirmModalHook } from './useConfirmModal';
|
| 5 |
import { ToastMessage } from './useToasts';
|
| 6 |
+
import { TFunction } from '../types';
|
| 7 |
|
| 8 |
|
| 9 |
interface UseCanvasFileUtilsProps {
|
|
|
|
| 22 |
confirmModalActions: {
|
| 23 |
requestConfirmation: ConfirmModalHook['requestConfirmation'];
|
| 24 |
};
|
| 25 |
+
t: TFunction;
|
| 26 |
}
|
| 27 |
|
| 28 |
export interface CanvasFileUtilsHook {
|
|
|
|
| 37 |
historyActions,
|
| 38 |
uiActions,
|
| 39 |
confirmModalActions,
|
| 40 |
+
t,
|
| 41 |
}: UseCanvasFileUtilsProps): CanvasFileUtilsHook => {
|
| 42 |
const { currentDataURL, canvasWidth, canvasHeight } = canvasState;
|
| 43 |
const { updateCanvasState } = historyActions;
|
|
|
|
| 84 |
const compositeDataURL = tempCanvas.toDataURL('image/png');
|
| 85 |
|
| 86 |
updateCanvasState(compositeDataURL, finalCanvasWidth, finalCanvasHeight);
|
| 87 |
+
showToast(t('imageLoadedSuccess'), 'success');
|
| 88 |
}
|
| 89 |
};
|
| 90 |
+
img.onerror = () => showToast(t('imageLoadError'), 'error');
|
| 91 |
img.src = imageDataUrl;
|
| 92 |
}
|
| 93 |
};
|
| 94 |
+
reader.onerror = () => showToast(t('imageReadError'), 'error');
|
| 95 |
reader.readAsDataURL(file);
|
| 96 |
if(event.target) event.target.value = '';
|
| 97 |
}
|
| 98 |
+
}, [canvasWidth, canvasHeight, updateCanvasState, showToast, t]);
|
| 99 |
|
| 100 |
const handleExportImage = useCallback(() => {
|
| 101 |
if (currentDataURL) {
|
|
|
|
| 105 |
document.body.appendChild(link);
|
| 106 |
link.click();
|
| 107 |
document.body.removeChild(link);
|
| 108 |
+
showToast(t('imageExported'), 'success');
|
| 109 |
} else {
|
| 110 |
+
showToast(t('noImageToExport'), 'info');
|
| 111 |
}
|
| 112 |
+
}, [currentDataURL, showToast, t]);
|
| 113 |
|
| 114 |
const handleClearCanvas = useCallback(() => {
|
| 115 |
const blankCanvas = getBlankCanvasDataURL(canvasWidth, canvasHeight);
|
| 116 |
updateCanvasState(blankCanvas, canvasWidth, canvasHeight);
|
| 117 |
+
showToast(t("canvasCleared"), "info");
|
| 118 |
+
}, [canvasWidth, canvasHeight, updateCanvasState, showToast, t]);
|
| 119 |
|
| 120 |
const handleCanvasSizeChange = useCallback((newWidth: number, newHeight: number) => {
|
| 121 |
if (newWidth === canvasWidth && newHeight === canvasHeight) {
|
| 122 |
return;
|
| 123 |
}
|
| 124 |
requestConfirmation(
|
| 125 |
+
t('confirmCanvasSizeChangeTitle'),
|
| 126 |
+
t('confirmCanvasSizeChangeMessage'),
|
| 127 |
() => {
|
| 128 |
const blankCanvas = getBlankCanvasDataURL(newWidth, newHeight);
|
| 129 |
updateCanvasState(blankCanvas, newWidth, newHeight);
|
| 130 |
setZoomLevel(0.5);
|
| 131 |
+
showToast(t('canvasResized', { width: newWidth, height: newHeight }), 'info');
|
| 132 |
},
|
| 133 |
{ isDestructive: true }
|
| 134 |
);
|
| 135 |
+
}, [canvasWidth, canvasHeight, requestConfirmation, updateCanvasState, setZoomLevel, showToast, t]);
|
| 136 |
|
| 137 |
return { handleLoadImageFile, handleExportImage, handleClearCanvas, handleCanvasSizeChange };
|
| 138 |
};
|
hooks/useConfirmModal.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React, { useState, useCallback } from 'react';
|
|
|
|
| 2 |
|
| 3 |
export interface ConfirmModalInfo {
|
| 4 |
isOpen: boolean;
|
|
@@ -35,7 +36,7 @@ export interface ConfirmModalHook {
|
|
| 35 |
closeConfirmModal: () => void;
|
| 36 |
}
|
| 37 |
|
| 38 |
-
export const useConfirmModal = (): ConfirmModalHook => {
|
| 39 |
const [confirmModalInfo, setConfirmModalInfo] = useState<ConfirmModalInfo>(initialConfirmModalState);
|
| 40 |
|
| 41 |
const requestConfirmation = useCallback(
|
|
@@ -58,11 +59,11 @@ export const useConfirmModal = (): ConfirmModalHook => {
|
|
| 58 |
setConfirmModalInfo(prev => ({ ...prev, isOpen: false })); // Close modal after action
|
| 59 |
},
|
| 60 |
isDestructive: options.isDestructive ?? false,
|
| 61 |
-
confirmText: options.confirmText ?? '
|
| 62 |
-
cancelText: options.cancelText ?? '
|
| 63 |
});
|
| 64 |
},
|
| 65 |
-
[]
|
| 66 |
);
|
| 67 |
|
| 68 |
const closeConfirmModal = useCallback(() => {
|
|
|
|
| 1 |
import React, { useState, useCallback } from 'react';
|
| 2 |
+
import { TFunction } from '../types';
|
| 3 |
|
| 4 |
export interface ConfirmModalInfo {
|
| 5 |
isOpen: boolean;
|
|
|
|
| 36 |
closeConfirmModal: () => void;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
export const useConfirmModal = (t: TFunction): ConfirmModalHook => {
|
| 40 |
const [confirmModalInfo, setConfirmModalInfo] = useState<ConfirmModalInfo>(initialConfirmModalState);
|
| 41 |
|
| 42 |
const requestConfirmation = useCallback(
|
|
|
|
| 59 |
setConfirmModalInfo(prev => ({ ...prev, isOpen: false })); // Close modal after action
|
| 60 |
},
|
| 61 |
isDestructive: options.isDestructive ?? false,
|
| 62 |
+
confirmText: options.confirmText ?? t('confirm'),
|
| 63 |
+
cancelText: options.cancelText ?? t('cancel'),
|
| 64 |
});
|
| 65 |
},
|
| 66 |
+
[t]
|
| 67 |
);
|
| 68 |
|
| 69 |
const closeConfirmModal = useCallback(() => {
|
hooks/useFullscreen.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
| 2 |
|
| 3 |
export interface FullscreenHook {
|
| 4 |
isFullscreenActive: boolean;
|
|
@@ -7,7 +8,8 @@ export interface FullscreenHook {
|
|
| 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>();
|
|
@@ -50,7 +52,7 @@ export const useFullscreen = (
|
|
| 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;
|
|
@@ -65,7 +67,7 @@ export const useFullscreen = (
|
|
| 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;
|
|
@@ -74,7 +76,7 @@ export const useFullscreen = (
|
|
| 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
|
|
|
|
| 1 |
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { TFunction } from '../types';
|
| 3 |
|
| 4 |
export interface FullscreenHook {
|
| 5 |
isFullscreenActive: boolean;
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
export const useFullscreen = (
|
| 11 |
+
showToast?: (message: string, type: 'info' | 'error') => void,
|
| 12 |
+
t?: TFunction,
|
| 13 |
): FullscreenHook => {
|
| 14 |
const [isFullscreenActive, setIsFullscreenActive] = useState<boolean>(false);
|
| 15 |
const [requestFullscreenSupportError, setRequestFullscreenSupportError] = useState<string | undefined>();
|
|
|
|
| 52 |
} else if (element.msRequestFullscreen) { // IE/Edge
|
| 53 |
await element.msRequestFullscreen();
|
| 54 |
} else {
|
| 55 |
+
const errorMsg = t ? t('fullscreenNotSupported') : "Fullscreen API is not supported by this browser.";
|
| 56 |
setRequestFullscreenSupportError(errorMsg);
|
| 57 |
if (showToast) showToast(errorMsg, "error");
|
| 58 |
return;
|
|
|
|
| 67 |
} else if ((document as any).msExitFullscreen) {
|
| 68 |
await (document as any).msExitFullscreen();
|
| 69 |
} else {
|
| 70 |
+
const errorMsg = t ? t('fullscreenExitError') : "Could not exit fullscreen. API not found.";
|
| 71 |
setRequestFullscreenSupportError(errorMsg);
|
| 72 |
if (showToast) showToast(errorMsg, "error");
|
| 73 |
return;
|
|
|
|
| 76 |
setRequestFullscreenSupportError(undefined);
|
| 77 |
} catch (err: any) {
|
| 78 |
console.error("Fullscreen API error:", err);
|
| 79 |
+
const errorMsg = t ? t('fullscreenChangeError', { message: err.message || 'Unknown error' }) : `Fullscreen mode change failed: ${err.message || 'Unknown error'}`;
|
| 80 |
setRequestFullscreenSupportError(errorMsg);
|
| 81 |
if (showToast) showToast(errorMsg, "error");
|
| 82 |
handleFullscreenChange(); // Ensure state reflects reality
|
metadata.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 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": ""
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "xpaintai_zh",
|
| 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": ""
|
package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"private": true,
|
| 4 |
"version": "0.0.0",
|
| 5 |
"type": "module",
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "xpaintai_zh",
|
| 3 |
"private": true,
|
| 4 |
"version": "0.0.0",
|
| 5 |
"type": "module",
|
translations.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
const translations = {
|
| 3 |
+
en: {
|
| 4 |
+
// Toolbar
|
| 5 |
+
undo: 'Undo',
|
| 6 |
+
redo: 'Redo',
|
| 7 |
+
loadImage: 'Load Image',
|
| 8 |
+
exportImage: 'Export Image',
|
| 9 |
+
shareAndEditWithAI: 'Share Canvas & Edit with AI',
|
| 10 |
+
clearCanvas: 'Clear Canvas',
|
| 11 |
+
switchToBrush: 'Switch to Brush',
|
| 12 |
+
switchToEraser: 'Switch to Eraser',
|
| 13 |
+
penColor: 'Pen Color',
|
| 14 |
+
penSize: 'Pen Size: {size}',
|
| 15 |
+
penSizeAria: 'Pen size {size} pixels',
|
| 16 |
+
openSettings: 'Open Settings',
|
| 17 |
+
undoAria: 'Undo last action',
|
| 18 |
+
redoAria: 'Redo last undone action',
|
| 19 |
+
loadImageAria: 'Load image from file',
|
| 20 |
+
exportImageAria: 'Export canvas as image',
|
| 21 |
+
shareAndEditWithAIAria: 'Share canvas by uploading and open AI edit options',
|
| 22 |
+
clearCanvasAria: 'Clear canvas',
|
| 23 |
+
switchToBrushAria: 'Switch to Brush mode',
|
| 24 |
+
switchToEraserAria: 'Switch to Eraser mode',
|
| 25 |
+
selectPenColorAria: 'Select pen color',
|
| 26 |
+
|
| 27 |
+
// AI Modal
|
| 28 |
+
aiModalTitle: 'Edit or Ask about Image with AI',
|
| 29 |
+
closeAiModalAria: 'Close AI edit modal',
|
| 30 |
+
aiPromptLabel: 'Describe what you want to change, or ask a question:',
|
| 31 |
+
aiPromptPlaceholder: "e.g., 'make the cat wear a party hat' or 'what is in this image?'",
|
| 32 |
+
errorPrefix: 'Error:',
|
| 33 |
+
aiAsk: 'Ask',
|
| 34 |
+
aiAsking: 'Asking...',
|
| 35 |
+
aiGenerateImage: 'Generate Image',
|
| 36 |
+
aiGenerating: 'Generating...',
|
| 37 |
+
|
| 38 |
+
// Settings Panel
|
| 39 |
+
settingsTitle: 'Application Settings',
|
| 40 |
+
closeSettingsAria: 'Close settings panel',
|
| 41 |
+
interface: 'Interface',
|
| 42 |
+
showZoomSlider: 'Show Zoom Slider',
|
| 43 |
+
toggleZoomSliderAria: 'Toggle Zoom Slider Visibility',
|
| 44 |
+
enterFullscreen: 'Enter Fullscreen',
|
| 45 |
+
exitFullscreen: 'Exit Fullscreen',
|
| 46 |
+
fullscreenAria: 'Enter Fullscreen',
|
| 47 |
+
exitFullscreenAria: 'Exit Fullscreen',
|
| 48 |
+
canvasDimensions: 'Canvas Dimensions',
|
| 49 |
+
canvasSize: 'Canvas Size',
|
| 50 |
+
canvasSizeDescription: 'Changing size will clear the current canvas and history.',
|
| 51 |
+
aiImageGeneration: 'AI Image Generation',
|
| 52 |
+
apiEndpoint: 'API Endpoint',
|
| 53 |
+
apiEndpointDescription: 'Select an API for image generation. Placeholders are filled automatically.',
|
| 54 |
+
pollinationsApi: 'Pollinations AI',
|
| 55 |
+
geminiSimpleGetApi: 'Gemini Simple Get',
|
| 56 |
+
aiImageDimensions: 'AI Image Dimensions',
|
| 57 |
+
aiImageDimensionsDescription: "Controls `width` & `height` params for APIs that support them (e.g., Pollinations AI).",
|
| 58 |
+
apiDefault: 'API Default (no size)',
|
| 59 |
+
matchCanvasSize: 'Match Canvas Size',
|
| 60 |
+
fixed1024: 'Fixed 1024x1024',
|
| 61 |
+
imageQuality: 'Image Quality',
|
| 62 |
+
imageQualityDescription: "Used for APIs that support a 'quality' parameter (e.g., Pollinations AI).",
|
| 63 |
+
qualityLow: 'Low',
|
| 64 |
+
qualityMedium: 'Medium',
|
| 65 |
+
qualityHigh: 'High (HD)',
|
| 66 |
+
done: 'Done',
|
| 67 |
+
|
| 68 |
+
// Confirm Modal
|
| 69 |
+
confirm: 'Confirm',
|
| 70 |
+
cancel: 'Cancel',
|
| 71 |
+
confirmCanvasSizeChangeTitle: 'Confirm Canvas Size Change',
|
| 72 |
+
confirmCanvasSizeChangeMessage: 'Changing canvas size will clear the current drawing and history. Are you sure you want to continue?',
|
| 73 |
+
|
| 74 |
+
// Zoom Slider
|
| 75 |
+
zoom: 'Zoom: {percentage}%',
|
| 76 |
+
zoomAria: 'Current zoom level {percentage} percent',
|
| 77 |
+
|
| 78 |
+
// Canvas
|
| 79 |
+
drawingCanvas: 'Drawing canvas',
|
| 80 |
+
initializingCanvas: 'Initializing Canvas...',
|
| 81 |
+
|
| 82 |
+
// App Loading
|
| 83 |
+
loadingCanvasMessage: 'Loading Your Canvas...',
|
| 84 |
+
loadingCanvasSubMessage: 'Getting things ready!',
|
| 85 |
+
|
| 86 |
+
// Toasts
|
| 87 |
+
imageLoadedSuccess: 'Image loaded successfully.',
|
| 88 |
+
imageLoadError: 'Error loading image file for processing.',
|
| 89 |
+
imageReadError: 'Error reading image file.',
|
| 90 |
+
imageExported: 'Image exported!',
|
| 91 |
+
noImageToExport: 'No image to export.',
|
| 92 |
+
canvasCleared: 'Canvas cleared.',
|
| 93 |
+
canvasResized: 'Canvas resized to {width}x{height}px. Drawing cleared.',
|
| 94 |
+
noCanvasContentToShare: 'No canvas content to share.',
|
| 95 |
+
uploadingImage: 'Uploading image...',
|
| 96 |
+
imageTooLarge: 'Image too large ({size}MB). Max 50MB.',
|
| 97 |
+
uploadFailed: 'Upload failed: {statusText}',
|
| 98 |
+
magicUploadError: 'Magic upload failed. Check console.',
|
| 99 |
+
imageUploadedAndCopied: 'Image uploaded! URL: {url} (Copied!)',
|
| 100 |
+
aiImageApplied: 'AI image applied to canvas!',
|
| 101 |
+
failedToCreateContext: 'Failed to create drawing context for AI image.',
|
| 102 |
+
failedToLoadAiImage: 'Failed to load the generated AI image. It might be an invalid image format.',
|
| 103 |
+
generatingAiImage: 'Generating AI image...',
|
| 104 |
+
aiGenFailed: 'AI image generation failed: {status} {statusText}',
|
| 105 |
+
aiGenInvalidImage: 'AI service did not return a valid image. Please try a different prompt or check the API endpoint.',
|
| 106 |
+
failedToReadAiData: 'Failed to read AI image data.',
|
| 107 |
+
unknownAiError: 'An unknown error occurred during AI image generation.',
|
| 108 |
+
enterPrompt: 'Please enter a prompt and ensure an image was uploaded.',
|
| 109 |
+
enterQuestion: 'Please enter a question about the image.',
|
| 110 |
+
unknownAskError: 'An unknown error occurred while asking AI.',
|
| 111 |
+
clipboardApiNotAvailable: 'Clipboard API not available.',
|
| 112 |
+
failedToCopyUrl: 'Failed to copy URL to clipboard.',
|
| 113 |
+
fullscreenNotSupported: 'Fullscreen API is not supported by this browser.',
|
| 114 |
+
fullscreenExitError: 'Could not exit fullscreen. API not found.',
|
| 115 |
+
fullscreenChangeError: 'Fullscreen mode change failed: {message}',
|
| 116 |
+
},
|
| 117 |
+
zh: {
|
| 118 |
+
// Toolbar
|
| 119 |
+
undo: '撤销',
|
| 120 |
+
redo: '重做',
|
| 121 |
+
loadImage: '加载图片',
|
| 122 |
+
exportImage: '导出图片',
|
| 123 |
+
shareAndEditWithAI: '分享并用AI编辑',
|
| 124 |
+
clearCanvas: '清空画布',
|
| 125 |
+
switchToBrush: '切换到画笔',
|
| 126 |
+
switchToEraser: '切换到橡皮擦',
|
| 127 |
+
penColor: '画笔颜色',
|
| 128 |
+
penSize: '画笔大小: {size}',
|
| 129 |
+
penSizeAria: '画笔大小 {size} 像素',
|
| 130 |
+
openSettings: '打开设置',
|
| 131 |
+
undoAria: '撤销上一步操作',
|
| 132 |
+
redoAria: '重做上一步撤销的操作',
|
| 133 |
+
loadImageAria: '从文件加载图片',
|
| 134 |
+
exportImageAria: '将画布导出为图片',
|
| 135 |
+
shareAndEditWithAIAria: '通过上传分享画布并打开AI编辑选项',
|
| 136 |
+
clearCanvasAria: '清空画布',
|
| 137 |
+
switchToBrushAria: '切换到画笔模式',
|
| 138 |
+
switchToEraserAria: '切换到橡皮擦模式',
|
| 139 |
+
selectPenColorAria: '选择画笔颜色',
|
| 140 |
+
|
| 141 |
+
// AI Modal
|
| 142 |
+
aiModalTitle: '使用 AI 编辑或询问图片',
|
| 143 |
+
closeAiModalAria: '关闭AI编辑窗口',
|
| 144 |
+
aiPromptLabel: '描述您想更改的内容,或提出一个问题:',
|
| 145 |
+
aiPromptPlaceholder: "例如,“给猫带上派对帽”或“这张图里有什么?”",
|
| 146 |
+
errorPrefix: '错误:',
|
| 147 |
+
aiAsk: '提问',
|
| 148 |
+
aiAsking: '提问中...',
|
| 149 |
+
aiGenerateImage: '生成图片',
|
| 150 |
+
aiGenerating: '生成中...',
|
| 151 |
+
|
| 152 |
+
// Settings Panel
|
| 153 |
+
settingsTitle: '应用设置',
|
| 154 |
+
closeSettingsAria: '关闭设置面板',
|
| 155 |
+
interface: '界面',
|
| 156 |
+
showZoomSlider: '显示缩放滑块',
|
| 157 |
+
toggleZoomSliderAria: '切换缩放滑块可见性',
|
| 158 |
+
enterFullscreen: '进入全屏',
|
| 159 |
+
exitFullscreen: '退出全屏',
|
| 160 |
+
fullscreenAria: '进入全屏',
|
| 161 |
+
exitFullscreenAria: '退出全屏',
|
| 162 |
+
canvasDimensions: '画布尺寸',
|
| 163 |
+
canvasSize: '画布大小',
|
| 164 |
+
canvasSizeDescription: '更改尺寸将清除当前画布和历史记录。',
|
| 165 |
+
aiImageGeneration: 'AI 图像生成',
|
| 166 |
+
apiEndpoint: 'API 端点',
|
| 167 |
+
apiEndpointDescription: '选择用于图像生成的 API。占位符会自动填充。',
|
| 168 |
+
pollinationsApi: 'Pollinations AI',
|
| 169 |
+
geminiSimpleGetApi: 'Gemini Simple Get',
|
| 170 |
+
aiImageDimensions: 'AI 图像尺寸',
|
| 171 |
+
aiImageDimensionsDescription: "控制支持它们的 API 的 `width` 和 `height` 参数(例如 Pollinations AI)。",
|
| 172 |
+
apiDefault: 'API 默认(无尺寸)',
|
| 173 |
+
matchCanvasSize: '匹配画布尺寸',
|
| 174 |
+
fixed1024: '固定 1024x1024',
|
| 175 |
+
imageQuality: '图像质量',
|
| 176 |
+
imageQualityDescription: "用于支持 'quality' 参数的 API(例如 Pollinations AI)。",
|
| 177 |
+
qualityLow: '低',
|
| 178 |
+
qualityMedium: '中',
|
| 179 |
+
qualityHigh: '高 (HD)',
|
| 180 |
+
done: '完成',
|
| 181 |
+
|
| 182 |
+
// Confirm Modal
|
| 183 |
+
confirm: '确认',
|
| 184 |
+
cancel: '取消',
|
| 185 |
+
confirmCanvasSizeChangeTitle: '确认更改画布尺寸',
|
| 186 |
+
confirmCanvasSizeChangeMessage: '更改画布尺寸将清除当前绘图和历史记录。您确定要继续吗?',
|
| 187 |
+
|
| 188 |
+
// Zoom Slider
|
| 189 |
+
zoom: '缩放: {percentage}%',
|
| 190 |
+
zoomAria: '当前缩放级别 {percentage} percent',
|
| 191 |
+
|
| 192 |
+
// Canvas
|
| 193 |
+
drawingCanvas: '绘画画布',
|
| 194 |
+
initializingCanvas: '正在初始化画布...',
|
| 195 |
+
|
| 196 |
+
// App Loading
|
| 197 |
+
loadingCanvasMessage: '正在加载您的画布...',
|
| 198 |
+
loadingCanvasSubMessage: '正在准备中!',
|
| 199 |
+
|
| 200 |
+
// Toasts
|
| 201 |
+
imageLoadedSuccess: '图片加载成功。',
|
| 202 |
+
imageLoadError: '处理图片文件时出错。',
|
| 203 |
+
imageReadError: '读取图片文件时出错。',
|
| 204 |
+
imageExported: '图片已导出!',
|
| 205 |
+
noImageToExport: '没有可导出的图片。',
|
| 206 |
+
canvasCleared: '画布已清空。',
|
| 207 |
+
canvasResized: '画布尺寸已调整为 {width}x{height}px。绘图已清除。',
|
| 208 |
+
noCanvasContentToShare: '没有可分享的画布内容。',
|
| 209 |
+
uploadingImage: '正在上传图片...',
|
| 210 |
+
imageTooLarge: '图片太大 ({size}MB)。最大 50MB。',
|
| 211 |
+
uploadFailed: '上传失败: {statusText}',
|
| 212 |
+
magicUploadError: '魔法上传失败。请检查控制台。',
|
| 213 |
+
imageUploadedAndCopied: '图片已上传!URL: {url} (已复制!)',
|
| 214 |
+
aiImageApplied: 'AI 图片已应用到画布!',
|
| 215 |
+
failedToCreateContext: '为 AI 图片创建绘图上下文失败。',
|
| 216 |
+
failedToLoadAiImage: '加载生成的 AI 图片失败。可能为无效的图片格式。',
|
| 217 |
+
generatingAiImage: '正在生成 AI 图片...',
|
| 218 |
+
aiGenFailed: 'AI 图片生成失败: {status} {statusText}',
|
| 219 |
+
aiGenInvalidImage: 'AI 服务未返回有效图片。请尝试其他提示或检查 API 端点。',
|
| 220 |
+
failedToReadAiData: '读取 AI 图片数据失败。',
|
| 221 |
+
unknownAiError: 'AI 图片生成期间发生未知错误。',
|
| 222 |
+
enterPrompt: '请输入提示并确保已上传图片。',
|
| 223 |
+
enterQuestion: '请输入关于图片的问题。',
|
| 224 |
+
unknownAskError: '询问 AI 时发生未知错误。',
|
| 225 |
+
clipboardApiNotAvailable: '剪贴板 API 不可用。',
|
| 226 |
+
failedToCopyUrl: '复制 URL 到剪贴板失败。',
|
| 227 |
+
fullscreenNotSupported: '此浏览器不支持全屏 API。',
|
| 228 |
+
fullscreenExitError: '无法退出全屏。未找到 API。',
|
| 229 |
+
fullscreenChangeError: '全屏模式更改失败: {message}',
|
| 230 |
+
},
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
type Language = keyof typeof translations;
|
| 234 |
+
export type TranslationKey = keyof typeof translations['en'];
|
| 235 |
+
|
| 236 |
+
const getLanguage = (): Language => {
|
| 237 |
+
if (typeof navigator !== 'undefined' && navigator.language) {
|
| 238 |
+
if (navigator.language.startsWith('zh')) {
|
| 239 |
+
return 'zh';
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
return 'en';
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
const currentLang = getLanguage();
|
| 246 |
+
|
| 247 |
+
export const getTranslator = () => {
|
| 248 |
+
const langDict = translations[currentLang];
|
| 249 |
+
const fallbackDict = translations.en;
|
| 250 |
+
|
| 251 |
+
return (key: TranslationKey, replacements?: Record<string, string | number>): string => {
|
| 252 |
+
let text: string = (langDict as any)[key] || (fallbackDict as any)[key];
|
| 253 |
+
if (replacements) {
|
| 254 |
+
Object.entries(replacements).forEach(([placeholder, value]) => {
|
| 255 |
+
text = text.replace(`{${placeholder}}`, String(value));
|
| 256 |
+
});
|
| 257 |
+
}
|
| 258 |
+
return text;
|
| 259 |
+
};
|
| 260 |
+
};
|
types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
| 1 |
+
import { TranslationKey } from './translations';
|
| 2 |
+
|
| 3 |
// No global shared complex types needed for now.
|
| 4 |
// Specific types/interfaces are defined within components or services where they are used.
|
| 5 |
// Example: export interface Point { x: number; y: number; } if it were broadly used.
|
| 6 |
+
export type TFunction = (key: TranslationKey, replacements?: Record<string, string | number>) => string;
|
utils/canvasUtils.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
|
|
|
|
|
|
|
| 2 |
export const getBlankCanvasDataURL = (width: number, height: number): string => {
|
| 3 |
const tempCanvas = document.createElement('canvas');
|
| 4 |
tempCanvas.width = width;
|
|
@@ -25,10 +27,11 @@ export const calculateSHA256 = async (blob: Blob): Promise<string> => {
|
|
| 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('
|
| 32 |
return false;
|
| 33 |
}
|
| 34 |
try {
|
|
@@ -36,7 +39,7 @@ export const copyToClipboard = async (
|
|
| 36 |
return true;
|
| 37 |
} catch (err) {
|
| 38 |
console.error('Failed to copy text: ', err);
|
| 39 |
-
showInfoToast('
|
| 40 |
return false;
|
| 41 |
}
|
| 42 |
};
|
|
|
|
| 1 |
|
| 2 |
+
import { TFunction } from '../types';
|
| 3 |
+
|
| 4 |
export const getBlankCanvasDataURL = (width: number, height: number): string => {
|
| 5 |
const tempCanvas = document.createElement('canvas');
|
| 6 |
tempCanvas.width = width;
|
|
|
|
| 27 |
|
| 28 |
export const copyToClipboard = async (
|
| 29 |
text: string,
|
| 30 |
+
showInfoToast: (message: string, type: 'info' | 'error') => void, // Callback for toast
|
| 31 |
+
t: TFunction
|
| 32 |
): Promise<boolean> => {
|
| 33 |
if (!navigator.clipboard) {
|
| 34 |
+
showInfoToast(t('clipboardApiNotAvailable'), 'info');
|
| 35 |
return false;
|
| 36 |
}
|
| 37 |
try {
|
|
|
|
| 39 |
return true;
|
| 40 |
} catch (err) {
|
| 41 |
console.error('Failed to copy text: ', err);
|
| 42 |
+
showInfoToast(t('failedToCopyUrl'), 'error');
|
| 43 |
return false;
|
| 44 |
}
|
| 45 |
};
|