diff --git a/package-lock.json b/package-lock.json
index 4f6945c..3cb9ca3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,12 @@
"name": "twitch-panels",
"version": "0.0.1",
"dependencies": {
- "svelte-konva": "^1.0.1"
+ "@types/cropperjs": "^1.1.5",
+ "@types/uuid": "^10.0.0",
+ "cropperjs": "^2.1.0",
+ "file-saver": "^2.0.5",
+ "svelte-konva": "^1.0.1",
+ "uuid": "^13.0.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
@@ -22,6 +27,126 @@
"vite": "^7.3.1"
}
},
+ "node_modules/@cropper/element": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.1.0.tgz",
+ "integrity": "sha512-2zELddqHQNmlvkPoiYzE5nxEjPE+C8nXoTPuvV3FvLp3YjBinc7qb73Icg9UXP0o9qC4+h9q96JgGo0AyMO/Ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-canvas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-canvas/-/element-canvas-2.1.0.tgz",
+ "integrity": "sha512-el+rfJpZxsD2q5XxDBA4fRczcrOqB65Lb7roqXOq8LKufwf4bPWA9C6DjNJJahh/TP94dsLIEy3tSkgRMDv3Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-crosshair": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-crosshair/-/element-crosshair-2.1.0.tgz",
+ "integrity": "sha512-0V589dAx8uZAfvJwdINLn76gfPQEafPH94ukjJ76uX0FCUovLaAVX+VRD/MDSYn0Mza/xejzmL9Dhd1DfemvmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-grid": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-grid/-/element-grid-2.1.0.tgz",
+ "integrity": "sha512-dEnk0rO+vp553LMvsPYgfrqVFcYXeVFrgFeavBYYEhAXtO40p7kN4rmLYLMMjaN+T/Mx2BATv6kUQpALKy2HLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-handle": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-handle/-/element-handle-2.1.0.tgz",
+ "integrity": "sha512-8BklWA4C/2GGAULupIWleSnGutECvYt3vx9flodqDfZpDEozws4LgLqmmzVuQmVkRVUdLnXdtx28kjgWLtzkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-image": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-image/-/element-image-2.1.0.tgz",
+ "integrity": "sha512-mXOV8ixJvG0XtTxLebYAKDjEkFbFOQnsF02hXPZk1yQSV0J+LLhN7a2NePrtKnoTsEV19fhhX3UorMoyGGxvzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/element-canvas": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-selection": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-selection/-/element-selection-2.1.0.tgz",
+ "integrity": "sha512-mtFtBl6HIa/s9TWohXw+Z5eJoeYTqylrIcHvS7oVv0uM7IyeRwBW65Q7z+KtLfq/LW+2Sw/XDyvR+VN/DawBPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/element-canvas": "^2.1.0",
+ "@cropper/element-image": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-shade": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-shade/-/element-shade-2.1.0.tgz",
+ "integrity": "sha512-zMdyqbb0lc0Vd1oj2Z1miIZvhyZG41OXMHvrNt0hNwblh0dVdrvtw48lnFDgRv+672vt2CNx7Q04GuvCQfPlgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/element-canvas": "^2.1.0",
+ "@cropper/element-selection": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/element-viewer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/element-viewer/-/element-viewer-2.1.0.tgz",
+ "integrity": "sha512-XnxlQuqHitd1FOFZ6E0yXAF5NYd/LyIvONLLHI9p1rJw747WYKUPxQaSYtFKF7IOizJu/8mMj++Zc1dZ5ZP3YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/element-canvas": "^2.1.0",
+ "@cropper/element-image": "^2.1.0",
+ "@cropper/element-selection": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/elements": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/elements/-/elements-2.1.0.tgz",
+ "integrity": "sha512-qvzlYDn3VQgPPpsCu6Gi1XUO0v3vpXQFSjjxcVijbXeNsl/eiKrN7H9/CEiRgi5vr8kXfd7ZvgYxBjUBbH+y+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/element": "^2.1.0",
+ "@cropper/element-canvas": "^2.1.0",
+ "@cropper/element-crosshair": "^2.1.0",
+ "@cropper/element-grid": "^2.1.0",
+ "@cropper/element-handle": "^2.1.0",
+ "@cropper/element-image": "^2.1.0",
+ "@cropper/element-selection": "^2.1.0",
+ "@cropper/element-shade": "^2.1.0",
+ "@cropper/element-viewer": "^2.1.0"
+ }
+ },
+ "node_modules/@cropper/utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@cropper/utils/-/utils-2.1.0.tgz",
+ "integrity": "sha512-wLtpZ4/UWgo+fGmG8NBWge8x5ehjfDe9ovleDfLy8kpwFaw43XXOEXQtRL1UNr0u4JZxaeO8FcXcolRWUUrlRQ==",
+ "license": "MIT"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -991,6 +1116,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/cropperjs": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@types/cropperjs/-/cropperjs-1.1.5.tgz",
+ "integrity": "sha512-ofxLqZgZK6qtTjPedXfnph2nodHueejdNLtH/ljaNbM8rGRZg17YdBORJ/xYtKBdsonc7bIYKLGBfTGu/IQtcg==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1007,6 +1138,12 @@
"undici-types": "~7.16.0"
}
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "license": "MIT"
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1072,6 +1209,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/cropperjs": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-2.1.0.tgz",
+ "integrity": "sha512-SsSDqdVRl+mjbIBkGWlk1gCGcc+HzBqCbH5EQ+1tkAFUdxq2KUGukXF1RqhmvXrrdrX7PDwSUkWgXS7E36KvGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@cropper/elements": "^2.1.0",
+ "@cropper/utils": "^2.1.0"
+ }
+ },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1163,6 +1310,12 @@
}
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
+ "license": "MIT"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1544,6 +1697,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
diff --git a/package.json b/package.json
index cb702c9..c68b132 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,11 @@
"vite": "^7.3.1"
},
"dependencies": {
- "svelte-konva": "^1.0.1"
+ "@types/cropperjs": "^1.1.5",
+ "@types/uuid": "^10.0.0",
+ "cropperjs": "^2.1.0",
+ "file-saver": "^2.0.5",
+ "svelte-konva": "^1.0.1",
+ "uuid": "^13.0.0"
}
}
diff --git a/src/components/ImageUpload.svelte b/src/components/ImageUpload.svelte
new file mode 100644
index 0000000..c8d1fc7
--- /dev/null
+++ b/src/components/ImageUpload.svelte
@@ -0,0 +1,410 @@
+
+
+
+ {#if uploadedImage}
+
+

+
+
+
+
+ {:else}
+
+
+
+
+
Загрузите изображение
+
Перетащите файл сюда, нажмите для выбора, или вставьте через Ctrl+V
+
+
+
+
+
+
+
+ {#if showUrlInput}
+
+ {/if}
+
+
+
• Поддерживаемые форматы: JPG, PNG, WebP, GIF
+
• Максимальный размер: 10MB
+
• Или вставьте изображение через Ctrl+V
+
+
+
+
+
{
+ const file = e.target.files?.[0];
+ if (file) handleFileUpload(file);
+ }}
+ />
+ {/if}
+
+ {#if errorMessage}
+
+ Ошибка:
+ {errorMessage}
+
+ {/if}
+
+ {#if $uiStore.isLoading}
+
+ {/if}
+
+
+
diff --git a/src/lib/services/imageService.ts b/src/lib/services/imageService.ts
new file mode 100644
index 0000000..21f682d
--- /dev/null
+++ b/src/lib/services/imageService.ts
@@ -0,0 +1,213 @@
+import { type ImageUploadResult, type ImageCropResult } from "../types/panel";
+import { ImageError } from "../types/errors";
+import {
+ validateImageFile,
+ validateImageUrl,
+ loadImage,
+ getImageDimensions,
+ calculateAspectRatioFit,
+} from "../utils/imageValidator";
+import { handleError, logError } from "../utils/errorHandler";
+
+export class ImageService {
+ async handleFileUpload(file: File): Promise {
+ try {
+ // Validate file
+ const validation = validateImageFile(file);
+ if (!validation.isValid) {
+ return {
+ success: false,
+ error: validation.error,
+ };
+ }
+
+ // Convert file to base64
+ const base64 = await this.fileToBase64(file);
+
+ return {
+ success: true,
+ image: base64,
+ };
+ } catch (error) {
+ logError(error, "File upload failed");
+ return {
+ success: false,
+ error: handleError(error, "Ошибка загрузки файла"),
+ };
+ }
+ }
+
+ async handleUrlUpload(url: string): Promise {
+ try {
+ // Validate URL
+ const urlValidation = validateImageUrl(url);
+ if (!urlValidation.isValid) {
+ return {
+ success: false,
+ error: urlValidation.error,
+ };
+ }
+
+ // Load image
+ const img = await loadImage(url);
+
+ // Validate loaded image
+ const imgValidation = getImageDimensions(img);
+ if (imgValidation.width === 0 || imgValidation.height === 0) {
+ return {
+ success: false,
+ error: "Не удалось загрузить изображение по указанному URL",
+ };
+ }
+
+ // Convert to base64
+ const base64 = await this.imageToBase64(img);
+
+ return {
+ success: true,
+ image: base64,
+ };
+ } catch (error) {
+ logError(error, "URL upload failed");
+ return {
+ success: false,
+ error: handleError(error, "Ошибка загрузки изображения по URL"),
+ };
+ }
+ }
+
+ async handlePasteUpload(pasteEvent: ClipboardEvent): Promise {
+ try {
+ const items = pasteEvent.clipboardData?.items;
+ if (!items || items.length === 0) {
+ return {
+ success: false,
+ error: "В буфер обмена не найдено изображений",
+ };
+ }
+
+ // Find image item
+ let imageItem: DataTransferItem | null = null;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.indexOf("image") !== -1) {
+ imageItem = items[i];
+ break;
+ }
+ }
+
+ if (!imageItem) {
+ return {
+ success: false,
+ error: "В буфер обмена не найдено изображений",
+ };
+ }
+
+ // Get file from clipboard item
+ const file = imageItem.getAsFile();
+ if (!file) {
+ return {
+ success: false,
+ error: "Не удалось получить файл из буфера обмена",
+ };
+ }
+
+ // Validate and process file
+ const validation = validateImageFile(file);
+ if (!validation.isValid) {
+ return {
+ success: false,
+ error: validation.error,
+ };
+ }
+
+ const base64 = await this.fileToBase64(file);
+
+ return {
+ success: true,
+ image: base64,
+ };
+ } catch (error) {
+ logError(error, "Paste upload failed");
+ return {
+ success: false,
+ error: handleError(error, "Ошибка вставки изображения"),
+ };
+ }
+ }
+
+ async cropImage(
+ imageSrc: string,
+ cropArea: { x: number; y: number; width: number; height: number },
+ ): Promise {
+ try {
+ const img = await loadImage(imageSrc);
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+
+ if (!ctx) {
+ throw new ImageError("Не удалось создать контекст canvas");
+ }
+
+ // Set canvas size to crop area
+ canvas.width = cropArea.width;
+ canvas.height = cropArea.height;
+
+ // Draw cropped image
+ ctx.drawImage(
+ img,
+ cropArea.x,
+ cropArea.y,
+ cropArea.width,
+ cropArea.height,
+ 0,
+ 0,
+ cropArea.width,
+ cropArea.height,
+ );
+
+ // Convert to base64
+ const croppedImage = canvas.toDataURL("image/png");
+
+ return {
+ success: true,
+ croppedImage,
+ };
+ } catch (error) {
+ logError(error, "Image cropping failed");
+ return {
+ success: false,
+ error: handleError(error, "Ошибка обрезки изображения"),
+ };
+ }
+ }
+
+ private fileToBase64(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(new ImageError("Ошибка чтения файла"));
+ reader.readAsDataURL(file);
+ });
+ }
+
+ private imageToBase64(img: HTMLImageElement): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+
+ if (!ctx) {
+ throw new ImageError("Не удалось создать контекст canvas");
+ }
+
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+
+ ctx.drawImage(img, 0, 0);
+ resolve(canvas.toDataURL("image/png"));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+}
diff --git a/src/lib/types/errors.ts b/src/lib/types/errors.ts
new file mode 100644
index 0000000..f29e2ef
--- /dev/null
+++ b/src/lib/types/errors.ts
@@ -0,0 +1,37 @@
+export class AppError extends Error {
+ constructor(
+ message: string,
+ public code: string,
+ public recoverable: boolean = true,
+ public details?: unknown,
+ ) {
+ super(message);
+ this.name = "AppError";
+ }
+}
+
+export class ImageError extends AppError {
+ constructor(message: string, recoverable: boolean = true) {
+ super(message, "IMAGE_ERROR", recoverable);
+ }
+}
+
+export class TextError extends AppError {
+ constructor(message: string, recoverable: boolean = true) {
+ super(message, "TEXT_ERROR", recoverable);
+ }
+}
+
+export class CanvasError extends AppError {
+ constructor(message: string, recoverable: boolean = true) {
+ super(message, "CANVAS_ERROR", recoverable);
+ }
+}
+
+export class StorageError extends AppError {
+ constructor(message: string, recoverable: boolean = true) {
+ super(message, "STORAGE_ERROR", recoverable);
+ }
+}
+
+export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
diff --git a/src/lib/types/panel.ts b/src/lib/types/panel.ts
new file mode 100644
index 0000000..167be6e
--- /dev/null
+++ b/src/lib/types/panel.ts
@@ -0,0 +1,45 @@
+export interface TextItem {
+ id: string;
+ text: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ x: number;
+ y: number;
+}
+
+export interface Panel {
+ id: string;
+ backgroundImage: string;
+ texts: TextItem[];
+ height: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface ImageUploadResult {
+ success: boolean;
+ image?: string;
+ error?: string;
+}
+
+export interface CropOptions {
+ width: number;
+ height: number;
+ x?: number;
+ y?: number;
+}
+
+export interface ImageCropResult {
+ success: boolean;
+ croppedImage?: string;
+ error?: string;
+}
+
+export interface UIState {
+ isLoading: boolean;
+ error: string | null;
+ currentStep: "upload" | "crop" | "text" | "preview";
+ showCropModal: boolean;
+ showTextManager: boolean;
+}
diff --git a/src/lib/utils/errorHandler.ts b/src/lib/utils/errorHandler.ts
new file mode 100644
index 0000000..465b9b6
--- /dev/null
+++ b/src/lib/utils/errorHandler.ts
@@ -0,0 +1,62 @@
+import { AppError, type ErrorType } from "../types/errors";
+
+export function handleError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
+ if (error instanceof AppError) {
+ return error.recoverable ? `Ошибка: ${error.message}. Попробуйте снова.` : `Критическая ошибка: ${error.message}`;
+ }
+
+ if (error instanceof Error) {
+ return `${defaultMessage}: ${error.message}`;
+ }
+
+ if (typeof error === "string") {
+ return `${defaultMessage}: ${error}`;
+ }
+
+ return `${defaultMessage}: Произошла неизвестная ошибка`;
+}
+
+export function isRecoverableError(error: unknown): boolean {
+ if (error instanceof AppError) {
+ return error.recoverable;
+ }
+ return false;
+}
+
+export function createError(message: string, code: string, recoverable: boolean = true, details?: unknown): AppError {
+ return new AppError(message, code, recoverable, details);
+}
+
+export function logError(error: unknown, context?: string): void {
+ console.error("Error occurred:", {
+ error,
+ context,
+ timestamp: new Date().toISOString(),
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+}
+
+export function retryOperation(
+ operation: () => Promise,
+ maxRetries: number = 3,
+ delayMs: number = 1000,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let attempt = 0;
+
+ const attemptOperation = () => {
+ attempt++;
+ operation()
+ .then(resolve)
+ .catch((error) => {
+ if (attempt >= maxRetries) {
+ reject(error);
+ } else {
+ setTimeout(attemptOperation, delayMs * attempt);
+ }
+ });
+ };
+
+ attemptOperation();
+ });
+}
diff --git a/src/lib/utils/imageValidator.ts b/src/lib/utils/imageValidator.ts
new file mode 100644
index 0000000..d907295
--- /dev/null
+++ b/src/lib/utils/imageValidator.ts
@@ -0,0 +1,97 @@
+import { ImageError } from "../types/errors";
+
+export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+export const SUPPORTED_FORMATS = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
+
+export interface ValidationResult {
+ isValid: boolean;
+ error?: string;
+}
+
+export function validateFileSize(file: File): ValidationResult {
+ if (file.size > MAX_FILE_SIZE) {
+ return {
+ isValid: false,
+ error: `Файл слишком большой. Максимальный размер: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
+ };
+ }
+ return { isValid: true };
+}
+
+export function validateFileType(file: File): ValidationResult {
+ if (!SUPPORTED_FORMATS.includes(file.type)) {
+ return {
+ isValid: false,
+ error: `Неподдерживаемый формат. Допустимые форматы: ${SUPPORTED_FORMATS.join(", ")}`,
+ };
+ }
+ return { isValid: true };
+}
+
+export function validateImageFile(file: File): ValidationResult {
+ const sizeValidation = validateFileSize(file);
+ if (!sizeValidation.isValid) {
+ return sizeValidation;
+ }
+
+ const typeValidation = validateFileType(file);
+ if (!typeValidation.isValid) {
+ return typeValidation;
+ }
+
+ return { isValid: true };
+}
+
+export function validateImageUrl(url: string): ValidationResult {
+ try {
+ new URL(url);
+ return { isValid: true };
+ } catch {
+ return {
+ isValid: false,
+ error: "Неверный URL формат",
+ };
+ }
+}
+
+export function validateImageElement(img: HTMLImageElement): ValidationResult {
+ if (!img.naturalWidth || !img.naturalHeight) {
+ return {
+ isValid: false,
+ error: "Изображение не может быть загружено или повреждено",
+ };
+ }
+ return { isValid: true };
+}
+
+export async function loadImage(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new ImageError("Не удалось загрузить изображение"));
+
+ img.src = src;
+ });
+}
+
+export function getImageDimensions(img: HTMLImageElement): { width: number; height: number } {
+ return {
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ };
+}
+
+export function calculateAspectRatioFit(
+ srcWidth: number,
+ srcHeight: number,
+ maxWidth: number,
+ maxHeight: number,
+): { width: number; height: number } {
+ const ratio = Math.min(maxWidth / srcWidth, maxHeight / srcHeight);
+ return {
+ width: srcWidth * ratio,
+ height: srcHeight * ratio,
+ };
+}
diff --git a/src/stores/panelStore.ts b/src/stores/panelStore.ts
new file mode 100644
index 0000000..12bc60e
--- /dev/null
+++ b/src/stores/panelStore.ts
@@ -0,0 +1,51 @@
+import { writable, type Writable } from "svelte/store";
+import { type Panel } from "../lib/types/panel";
+import { v4 as uuidv4 } from "uuid";
+
+export const panelStore: Writable = writable(null);
+
+export const createEmptyPanel = (height: number = 100): Panel => {
+ return {
+ id: uuidv4(),
+ backgroundImage: "",
+ texts: [],
+ height,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+};
+
+export const updatePanel = (panel: Panel, updates: Partial): Panel => {
+ return {
+ ...panel,
+ ...updates,
+ updatedAt: new Date(),
+ };
+};
+
+export const addTextToPanel = (panel: Panel, text: string): Panel => {
+ const newText = {
+ id: uuidv4(),
+ text,
+ fontSize: 18,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ x: 160, // center horizontally
+ y: 50, // center vertically
+ };
+
+ return updatePanel(panel, {
+ texts: [...panel.texts, newText],
+ });
+};
+
+export const updateTextInPanel = (panel: Panel, textId: string, updates: Partial): Panel => {
+ const updatedTexts = panel.texts.map((text) => (text.id === textId ? { ...text, ...updates } : text));
+
+ return updatePanel(panel, { texts: updatedTexts });
+};
+
+export const removeTextFromPanel = (panel: Panel, textId: string): Panel => {
+ const updatedTexts = panel.texts.filter((text) => text.id !== textId);
+ return updatePanel(panel, { texts: updatedTexts });
+};
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts
new file mode 100644
index 0000000..d09b157
--- /dev/null
+++ b/src/stores/uiStore.ts
@@ -0,0 +1,44 @@
+import { writable, type Writable } from "svelte/store";
+import { type UIState } from "../lib/types/panel";
+
+export const uiStore: Writable = writable({
+ isLoading: false,
+ error: null,
+ currentStep: "upload",
+ showCropModal: false,
+ showTextManager: false,
+});
+
+export const setLoading = (loading: boolean): void => {
+ uiStore.update((state) => ({ ...state, isLoading: loading }));
+};
+
+export const setError = (error: string | null): void => {
+ uiStore.update((state) => ({ ...state, error }));
+};
+
+export const clearError = (): void => {
+ setError(null);
+};
+
+export const setCurrentStep = (step: UIState["currentStep"]): void => {
+ uiStore.update((state) => ({ ...state, currentStep: step }));
+};
+
+export const showCropModal = (show: boolean): void => {
+ uiStore.update((state) => ({ ...state, showCropModal: show }));
+};
+
+export const showTextManager = (show: boolean): void => {
+ uiStore.update((state) => ({ ...state, showTextManager: show }));
+};
+
+export const resetUI = (): void => {
+ uiStore.set({
+ isLoading: false,
+ error: null,
+ currentStep: "upload",
+ showCropModal: false,
+ showTextManager: false,
+ });
+};