├── metadata.json
├── .gitignore
├── index.tsx
├── package.json
├── vite.config.ts
├── README.md
├── tsconfig.json
├── index.html
└── App.tsx
/metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Image Tiler",
3 | "description": "An application to slice an uploaded image into smaller, downloadable tiles based on user-specified dimensions.",
4 | "requestFramePermissions": []
5 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
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?
25 |
--------------------------------------------------------------------------------
/index.tsx:
--------------------------------------------------------------------------------
1 |
2 | import React from 'react';
3 | import ReactDOM from 'react-dom/client';
4 | import App from './App';
5 |
6 | const rootElement = document.getElementById('root');
7 | if (!rootElement) {
8 | throw new Error("Could not find root element to mount to");
9 | }
10 |
11 | const root = ReactDOM.createRoot(rootElement);
12 | root.render(
13 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "image-tiler",
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.1",
13 | "react-dom": "^19.1.1"
14 | },
15 | "devDependencies": {
16 | "@types/node": "^22.14.0",
17 | "@vitejs/plugin-react": "^5.0.0",
18 | "typescript": "~5.8.2",
19 | "vite": "^6.2.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig, loadEnv } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 |
5 | export default defineConfig(({ mode }) => {
6 | const env = loadEnv(mode, '.', '');
7 | return {
8 | plugins: [react()],
9 | define: {
10 | 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
11 | 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
12 | },
13 | resolve: {
14 | alias: {
15 | '@': path.resolve(__dirname, '.'),
16 | }
17 | }
18 | };
19 | });
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | # Run and deploy your AI Studio app
6 |
7 | This contains everything you need to run your app locally.
8 |
9 | View your app in AI Studio: https://ai.studio/apps/drive/1ukhuErKQYk1vk6vMANYKOk9Uayf4pu-s
10 |
11 | ## Run Locally
12 |
13 | **Prerequisites:** Node.js
14 |
15 |
16 | 1. Install dependencies:
17 | `npm install`
18 | 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19 | 3. Run the app:
20 | `npm run dev`
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "experimentalDecorators": true,
5 | "useDefineForClassFields": false,
6 | "module": "ESNext",
7 | "lib": [
8 | "ES2022",
9 | "DOM",
10 | "DOM.Iterable"
11 | ],
12 | "skipLibCheck": true,
13 | "types": [
14 | "node"
15 | ],
16 | "moduleResolution": "bundler",
17 | "isolatedModules": true,
18 | "moduleDetection": "force",
19 | "allowJs": true,
20 | "jsx": "react-jsx",
21 | "paths": {
22 | "@/*": [
23 | "./*"
24 | ]
25 | },
26 | "allowImportingTsExtensions": true,
27 | "noEmit": true
28 | }
29 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Image Tiler Pro
8 |
9 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 |
2 | import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
3 |
4 | const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
5 |
8 | );
9 |
10 | const DownloadIcon: React.FC<{ className?: string }> = ({ className }) => (
11 |
14 | );
15 |
16 | const ResetIcon: React.FC<{ className?: string }> = ({ className }) => (
17 |
20 | );
21 |
22 | const ZoomInIcon: React.FC<{ className?: string }> = ({ className }) => (
23 |
26 | );
27 |
28 | const ZoomOutIcon: React.FC<{ className?: string }> = ({ className }) => (
29 |
32 | );
33 |
34 | const FitScreenIcon: React.FC<{ className?: string }> = ({ className }) => (
35 |
38 | );
39 |
40 | const CopyIcon: React.FC<{ className?: string }> = ({ className }) => (
41 |
44 | );
45 |
46 | interface ModalState {
47 | isOpen: boolean;
48 | col: number;
49 | row: number;
50 | }
51 |
52 | const SingleTileModal: React.FC<{
53 | modalData: ModalState;
54 | fileNameTemplate: string;
55 | onClose: () => void;
56 | generateTileDataUrl: (col: number, row: number) => string | null;
57 | downloadDataUrl: (dataUrl: string, filename: string) => void;
58 | }> = ({ modalData, fileNameTemplate, onClose, generateTileDataUrl, downloadDataUrl }) => {
59 | if (!modalData.isOpen) return null;
60 |
61 | const { col, row } = modalData;
62 |
63 | const [fileName, setFileName] = useState(`${fileNameTemplate}_${row}_${col}`);
64 | const [saveTileAsBase64, setSaveTileAsBase64] = useState(false);
65 | const [copied, setCopied] = useState(false);
66 | const [base64Output, setBase64Output] = useState(null);
67 |
68 | useEffect(() => {
69 | if (modalData.isOpen) {
70 | setFileName(`${fileNameTemplate}_${row}_${col}`);
71 | setSaveTileAsBase64(false);
72 | setCopied(false);
73 | setBase64Output(null);
74 | }
75 | }, [modalData, fileNameTemplate, row, col]);
76 |
77 | const handleDownload = () => {
78 | const dataUrl = generateTileDataUrl(col, row);
79 | if (!dataUrl) return;
80 |
81 | if (saveTileAsBase64) {
82 | setBase64Output(dataUrl);
83 | } else {
84 | downloadDataUrl(dataUrl, `${fileName}.png`);
85 | onClose();
86 | }
87 | };
88 |
89 | const handleCopy = () => {
90 | if (!base64Output) return;
91 | navigator.clipboard.writeText(base64Output).then(() => {
92 | setCopied(true);
93 | setTimeout(() => setCopied(false), 2000);
94 | });
95 | };
96 |
97 | const handleBack = () => {
98 | setBase64Output(null);
99 | };
100 |
101 | return (
102 |
103 |
e.stopPropagation()}>
104 | {base64Output ? (
105 |
106 |
Base64 Строка
107 |
112 |
113 |
117 |
120 |
121 |
122 | ) : (
123 |
124 |
Скачать тайл ({col}, {row})
125 |
126 |
127 | setFileName(e.target.value)}
132 | className="w-full bg-slate-900 border border-slate-600 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-cyan-500"
133 | />
134 |
135 |
136 | setSaveTileAsBase64(e.target.checked)}
141 | className="h-4 w-4 rounded bg-slate-700 border-slate-600 text-cyan-500 focus:ring-cyan-600"
142 | />
143 |
144 |
145 |
146 |
150 |
153 |
154 |
155 | )}
156 |
157 |
158 | );
159 | };
160 |
161 | export default function App() {
162 | const [image, setImage] = useState(null);
163 | const [imageName, setImageName] = useState('');
164 | const [tileWidth, setTileWidth] = useState('128');
165 | const [tileHeight, setTileHeight] = useState('128');
166 | const [isProcessing, setIsProcessing] = useState(false);
167 | const [scale, setScale] = useState(1);
168 | const [position, setPosition] = useState({ x: 0, y: 0 });
169 | const [isPanning, setIsPanning] = useState(false);
170 | const [fileNameTemplate, setFileNameTemplate] = useState('');
171 | const [saveAsBase64, setSaveAsBase64] = useState(false);
172 | const [modalData, setModalData] = useState({ isOpen: false, col: 0, row: 0 });
173 |
174 | const fileInputRef = useRef(null);
175 | const previewContainerRef = useRef(null);
176 | const lastMousePositionRef = useRef({ x: 0, y: 0 });
177 |
178 | const numTileWidth = useMemo(() => Math.max(1, parseInt(tileWidth) || 1), [tileWidth]);
179 | const numTileHeight = useMemo(() => Math.max(1, parseInt(tileHeight) || 1), [tileHeight]);
180 |
181 | const gridDimensions = useMemo(() => {
182 | if (!image || numTileWidth <= 0 || numTileHeight <= 0) return { cols: 0, rows: 0 };
183 | return {
184 | cols: Math.ceil(image.naturalWidth / numTileWidth),
185 | rows: Math.ceil(image.naturalHeight / numTileHeight),
186 | };
187 | }, [image, numTileWidth, numTileHeight]);
188 |
189 | const fitToScreen = useCallback(() => {
190 | if (!image || !previewContainerRef.current) return;
191 | const { clientWidth: containerWidth, clientHeight: containerHeight } = previewContainerRef.current;
192 | const { naturalWidth: imgWidth, naturalHeight: imgHeight } = image;
193 |
194 | if (imgWidth === 0 || imgHeight === 0) return;
195 |
196 | const scaleX = containerWidth / imgWidth;
197 | const scaleY = containerHeight / imgHeight;
198 | const newScale = Math.min(scaleX, scaleY) * 0.95; // 95% to have some padding
199 |
200 | const newX = (containerWidth - imgWidth * newScale) / 2;
201 | const newY = (containerHeight - imgHeight * newScale) / 2;
202 |
203 | setScale(newScale);
204 | setPosition({ x: newX, y: newY });
205 | }, [image]);
206 |
207 | useEffect(() => {
208 | fitToScreen();
209 | window.addEventListener('resize', fitToScreen);
210 | return () => window.removeEventListener('resize', fitToScreen);
211 | }, [fitToScreen]);
212 |
213 |
214 | const handleImageUpload = (event: React.ChangeEvent) => {
215 | const file = event.target.files?.[0];
216 | if (!file) return;
217 |
218 | const reader = new FileReader();
219 | reader.onload = (e) => {
220 | const img = new Image();
221 | img.onload = () => {
222 | setImage(img);
223 | const nameWithoutExtension = file.name.split('.').slice(0, -1).join('.') || 'tile';
224 | setImageName(file.name);
225 | setFileNameTemplate(nameWithoutExtension);
226 | };
227 | img.src = e.target?.result as string;
228 | };
229 | reader.readAsDataURL(file);
230 | };
231 |
232 | const handleUploadClick = () => {
233 | fileInputRef.current?.click();
234 | };
235 |
236 | const handleReset = () => {
237 | setImage(null);
238 | setImageName('');
239 | setTileWidth('128');
240 | setTileHeight('128');
241 | setFileNameTemplate('');
242 | setSaveAsBase64(false);
243 | setModalData({ isOpen: false, col: 0, row: 0 });
244 | if(fileInputRef.current) {
245 | fileInputRef.current.value = "";
246 | }
247 | };
248 |
249 | const generateTileDataUrl = useCallback((col: number, row: number): string | null => {
250 | if (!image || numTileWidth <= 0 || numTileHeight <= 0) return null;
251 |
252 | const sx = col * numTileWidth;
253 | const sy = row * numTileHeight;
254 |
255 | if (sx >= image.naturalWidth || sy >= image.naturalHeight) return null;
256 |
257 | const sWidth = Math.min(numTileWidth, image.naturalWidth - sx);
258 | const sHeight = Math.min(numTileHeight, image.naturalHeight - sy);
259 |
260 | const canvas = document.createElement('canvas');
261 | canvas.width = sWidth;
262 | canvas.height = sHeight;
263 | const ctx = canvas.getContext('2d');
264 |
265 | if (!ctx) return null;
266 |
267 | ctx.drawImage(image, sx, sy, sWidth, sHeight, 0, 0, sWidth, sHeight);
268 |
269 | return canvas.toDataURL('image/png');
270 | }, [image, numTileWidth, numTileHeight]);
271 |
272 |
273 | const downloadDataUrl = (dataUrl: string, filename: string) => {
274 | const link = document.createElement('a');
275 | link.href = dataUrl;
276 | link.download = filename;
277 | document.body.appendChild(link);
278 | link.click();
279 | document.body.removeChild(link);
280 | };
281 |
282 | const downloadAllTiles = async () => {
283 | if (!image) return;
284 | setIsProcessing(true);
285 |
286 | const { cols, rows } = gridDimensions;
287 |
288 | if (saveAsBase64) {
289 | const base64Collection: { [key: string]: string } = {};
290 | for (let r = 0; r < rows; r++) {
291 | for (let c = 0; c < cols; c++) {
292 | const dataUrl = generateTileDataUrl(c, r);
293 | if (dataUrl) {
294 | const key = `${fileNameTemplate}_${r}_${c}`;
295 | base64Collection[key] = dataUrl;
296 | }
297 | }
298 | }
299 | await new Promise(resolve => setTimeout(resolve, 100));
300 | const jsonString = JSON.stringify(base64Collection, null, 2);
301 | const blob = new Blob([jsonString], { type: 'application/json' });
302 | const url = URL.createObjectURL(blob);
303 | downloadDataUrl(url, `${fileNameTemplate}.json`);
304 | URL.revokeObjectURL(url);
305 | } else {
306 | for (let r = 0; r < rows; r++) {
307 | for (let c = 0; c < cols; c++) {
308 | const dataUrl = generateTileDataUrl(c, r);
309 | if (dataUrl) {
310 | downloadDataUrl(dataUrl, `${fileNameTemplate}_${r}_${c}.png`);
311 | }
312 | if ((r * cols + c) % 10 === 0) {
313 | await new Promise(resolve => setTimeout(resolve, 50));
314 | }
315 | }
316 | }
317 | }
318 |
319 | setIsProcessing(false);
320 | };
321 |
322 | const handleMouseDown = (e: React.MouseEvent) => {
323 | e.preventDefault();
324 | setIsPanning(true);
325 | lastMousePositionRef.current = { x: e.clientX, y: e.clientY };
326 | };
327 |
328 | const handleMouseUp = () => setIsPanning(false);
329 | const handleMouseLeave = () => setIsPanning(false);
330 |
331 | const handleMouseMove = (e: React.MouseEvent) => {
332 | if (!isPanning) return;
333 | const dx = e.clientX - lastMousePositionRef.current.x;
334 | const dy = e.clientY - lastMousePositionRef.current.y;
335 | setPosition({ x: position.x + dx, y: position.y + dy });
336 | lastMousePositionRef.current = { x: e.clientX, y: e.clientY };
337 | };
338 |
339 | const handleWheel = (e: React.WheelEvent) => {
340 | e.preventDefault();
341 | if (!previewContainerRef.current) return;
342 |
343 | const rect = previewContainerRef.current.getBoundingClientRect();
344 | const mouseX = e.clientX - rect.left;
345 | const mouseY = e.clientY - rect.top;
346 |
347 | const zoomFactor = 1.1;
348 | const newScale = e.deltaY < 0 ? scale * zoomFactor : scale / zoomFactor;
349 | const clampedScale = Math.max(0.1, Math.min(newScale, 15));
350 |
351 | const newX = mouseX - ((mouseX - position.x) / scale) * clampedScale;
352 | const newY = mouseY - ((mouseY - position.y) / scale) * clampedScale;
353 |
354 | setScale(clampedScale);
355 | setPosition({ x: newX, y: newY });
356 | };
357 |
358 | const zoom = (direction: 'in' | 'out') => {
359 | if (!previewContainerRef.current) return;
360 | const { clientWidth, clientHeight } = previewContainerRef.current;
361 | const centerX = clientWidth / 2;
362 | const centerY = clientHeight / 2;
363 |
364 | const zoomFactor = 1.5;
365 | const newScale = direction === 'in' ? scale * zoomFactor : scale / zoomFactor;
366 | const clampedScale = Math.max(0.1, Math.min(newScale, 15));
367 |
368 | const newX = centerX - ((centerX - position.x) / scale) * clampedScale;
369 | const newY = centerY - ((centerY - position.y) / scale) * clampedScale;
370 |
371 | setScale(clampedScale);
372 | setPosition({ x: newX, y: newY });
373 | };
374 |
375 | const openSingleTileModal = (col: number, row: number) => {
376 | setModalData({ isOpen: true, col, row });
377 | };
378 |
379 | const renderUploadScreen = () => (
380 |
381 |
382 |
Нарезка Тайлов
383 |
Загрузите изображение, чтобы разрезать его на тайлы.
384 |
391 |
398 |
399 |
400 | );
401 |
402 | const renderTilingScreen = () => (
403 |
404 |
405 |
406 |
Параметры
407 |
408 |
{imageName}
409 |
{image?.naturalWidth} x {image?.naturalHeight} px
410 |
411 |
412 |
413 |
434 |
435 |
436 |
437 | setFileNameTemplate(e.target.value)}
442 | className="w-full bg-slate-900 border border-slate-600 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-cyan-500"
443 | placeholder="например, my_tile"
444 | />
445 |
446 |
447 |
448 | setSaveAsBase64(e.target.checked)}
453 | className="h-4 w-4 rounded bg-slate-700 border-slate-600 text-cyan-500 focus:ring-cyan-600"
454 | />
455 |
456 |
457 |
458 |
459 |
479 |
486 |
487 |
488 |
489 |
490 |
499 | {image && (
500 |
510 |

511 |
518 | {Array.from({ length: gridDimensions.rows * gridDimensions.cols }).map((_, index) => {
519 | const col = index % gridDimensions.cols;
520 | const row = Math.floor(index / gridDimensions.cols);
521 | return (
522 |
!isPanning && openSingleTileModal(col, row)}
525 | className="border border-cyan-500/30 hover:bg-cyan-500/30 transition-colors duration-200 cursor-pointer"
526 | title={`Настроить скачивание тайла (${col}, ${row})`}
527 | />
528 | );
529 | })}
530 |
531 |
532 | )}
533 |
534 |
535 |
536 |
{Math.round(scale * 100)}%
537 |
538 |
539 |
540 |
541 |
542 |
543 | );
544 |
545 | return (
546 |
547 |
548 |
549 | Image
550 | Tiler
551 |
552 |
553 |
554 | {image ? renderTilingScreen() : renderUploadScreen()}
555 |
556 | setModalData({ isOpen: false, col: 0, row: 0 })}
560 | generateTileDataUrl={generateTileDataUrl}
561 | downloadDataUrl={downloadDataUrl}
562 | />
563 |
564 | );
565 | }
566 |
--------------------------------------------------------------------------------