feat: start implement mvp
This commit is contained in:
@@ -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<ImageUploadResult> {
|
||||
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<ImageUploadResult> {
|
||||
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<ImageUploadResult> {
|
||||
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<ImageCropResult> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 1000,
|
||||
): Promise<T> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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<HTMLImageElement> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user