diff --git a/package-lock.json b/package-lock.json index df0c5f6..8f1809a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { - "name": "image-web-editor", - "version": "1.3.0", + "name": "piclet-web-editor", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "image-web-editor", - "version": "1.3.0", + "name": "piclet-web-editor", + "version": "1.4.0", "dependencies": { + "@withease/factories": "^1.0.5", "clsx": "^2.1.1", "effector": "^23.4.4", "effector-react": "^23.3.0", @@ -2620,6 +2621,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@withease/factories": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@withease/factories/-/factories-1.0.5.tgz", + "integrity": "sha512-8qcAyBDxuI3F1Qf5ib39pzSfSma9TxyVH/ALOrtkaqnlTuRni76BMbwUyPgCTaEefPOO8sOQKe4SxZbs3p51dQ==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", diff --git a/package.json b/package.json index 2bdfba0..937fe1d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "piclet-web-editor", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", @@ -21,6 +21,7 @@ ] }, "dependencies": { + "@withease/factories": "^1.0.5", "clsx": "^2.1.1", "effector": "^23.4.4", "effector-react": "^23.3.0", diff --git a/src/entities/image/index.ts b/src/entities/image/index.ts index 7f34c4f..47cfeaf 100644 --- a/src/entities/image/index.ts +++ b/src/entities/image/index.ts @@ -12,6 +12,7 @@ export { mountCanvas, resetFilters, unmountCanvas, + imageUploadBaseFx, } from './model'; // Stores @@ -31,3 +32,6 @@ export { INITIAL_FILTERS } from './constants'; // Types export { FilterKey } from './types'; export type { Filters, PointRecord } from './types'; + +// Utils +export { loadImageFromUrl } from './lib'; diff --git a/src/entities/image/lib.ts b/src/entities/image/lib.ts index a86d88e..78f9a87 100644 --- a/src/entities/image/lib.ts +++ b/src/entities/image/lib.ts @@ -1,6 +1,37 @@ import { DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS } from './constants'; import type { FilterKey } from './types'; +/** + * Загружает изображение по URL и возвращает Promise с HTMLImageElement. + * @param url - URL изображения + * @param onRevokeUrl - Опциональный колбэк для освобождения ресурсов (например, blob URL) + * @returns Promise с загруженным изображением + */ +export function loadImageFromUrl( + url: string, + onRevokeUrl?: () => void, +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + if (onRevokeUrl) { + onRevokeUrl(); + } + resolve(img); + }; + + img.onerror = () => { + if (onRevokeUrl) { + onRevokeUrl(); + } + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); +} + /** * Опции для настройки отображения сетки */ diff --git a/src/entities/image/model.ts b/src/entities/image/model.ts index 56275ce..5b6efd1 100644 --- a/src/entities/image/model.ts +++ b/src/entities/image/model.ts @@ -1,4 +1,5 @@ import { + attach, createEffect, createEvent, createStore, @@ -137,6 +138,8 @@ export class CanvasEditor { /** * Загружает изображение по URL. * @param url - Ссылка на изображение + * @todo Заменить на общую утилиту loadImageFromUrl из ~/entities/image/lib + * после добавления поддержки crossOrigin в loadImageFromUrl */ public async loadImage(url: string): Promise { return new Promise((resolve, reject) => { @@ -394,9 +397,10 @@ export const filtersChanged = createEvent<{ value: Filters[FilterKey]; }>(); -const imageUploadFx = createEffect(async (file: File) => { +export const imageUploadBaseFx = createEffect(async (file: File) => { return URL.createObjectURL(file); }); +const imageUploadFx = attach({ effect: imageUploadBaseFx }); // Events export const mountCanvas = createEvent<{ diff --git a/src/features/download-image/__tests__/model.test.ts b/src/features/download-image/__tests__/model.test.ts index 06c1c63..0066929 100644 --- a/src/features/download-image/__tests__/model.test.ts +++ b/src/features/download-image/__tests__/model.test.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import { allSettled, fork } from 'effector'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { $canvas, @@ -15,6 +14,8 @@ describe('Download Image Feature', () => { let mockEditor: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockLink: any; + let createElementSpy: ReturnType; + let createObjectURLSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -30,37 +31,32 @@ describe('Download Image Feature', () => { remove: vi.fn(), }; - // Mock document.createElement - - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).document.createElement = vi - .fn() + // Mock document.createElement using vi.spyOn + createElementSpy = vi + .spyOn(document, 'createElement') .mockImplementation((tag: string) => { if (tag === 'a') { - return mockLink; + return mockLink as unknown as HTMLElement; } return document.createElement(tag); }); - // Mock URL methods - - // @ts-ignore - global.URL.createObjectURL = vi.fn().mockReturnValue('blob:test'); + // Mock URL methods using vi.spyOn + createObjectURLSpy = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:test'); - // @ts-ignore - global.URL.revokeObjectURL = vi.fn(); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); - // Mock window - - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).window = { - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(global as any).window, + // Mock window using vi.stubGlobal + vi.stubGlobal('window', { + ...globalThis.window, showSaveFilePicker: undefined, - }; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it('should throw error if no image is loaded', async () => { @@ -71,7 +67,7 @@ describe('Download Image Feature', () => { // Проверяем, что эффект завершится с ошибкой при отсутствии изображения const result = await allSettled(downloadImageFx, { scope, - // @ts-expect-error + // @ts-expect-error - testing error case with null image params: { image: null, quality: 100, editor: mockEditor }, }); expect(result.status).toBe('fail'); @@ -89,14 +85,12 @@ describe('Download Image Feature', () => { }; const mockShowSaveFilePicker = vi.fn().mockResolvedValue(mockFileHandle); - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).window = { - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(global as any).window, + + // Use vi.stubGlobal for window + vi.stubGlobal('window', { + ...globalThis.window, showSaveFilePicker: mockShowSaveFilePicker, - }; + }); const mockFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); const scope = fork({ @@ -121,15 +115,12 @@ describe('Download Image Feature', () => { }); it('should fallback to download link if File System API is not available', async () => { - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).window = { - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(global as any).window, + // Use vi.stubGlobal for window + vi.stubGlobal('window', { + ...globalThis.window, showSaveFilePicker: undefined, - }; - // @ts-ignore + }); + const mockFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); const scope = fork({ values: [[$canvas, mockEditor]], @@ -138,25 +129,22 @@ describe('Download Image Feature', () => { await allSettled(imageUploadStarted, { scope, params: mockFile }); await allSettled(donwloadButtonClicked, { scope }); - expect(document.createElement).toHaveBeenCalledWith('a'); + expect(createElementSpy).toHaveBeenCalledWith('a'); expect(mockLink.click).toHaveBeenCalled(); expect(mockLink.download).toBe('test.jpg'); - // @ts-ignore - expect(global.URL.createObjectURL).toHaveBeenCalled(); + expect(createObjectURLSpy).toHaveBeenCalled(); }); it('should handle user cancellation gracefully', async () => { const mockShowSaveFilePicker = vi.fn().mockRejectedValue({ name: 'AbortError', }); - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).window = { - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(global as any).window, + + // Use vi.stubGlobal for window + vi.stubGlobal('window', { + ...globalThis.window, showSaveFilePicker: mockShowSaveFilePicker, - }; + }); const mockFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); const scope = fork({ @@ -169,7 +157,7 @@ describe('Download Image Feature', () => { // При отмене пользователем эффект должен завершиться успешно (возвращает false) expect(mockShowSaveFilePicker).toHaveBeenCalled(); // Проверяем, что fallback на скачивание не был вызван - expect(document.createElement).not.toHaveBeenCalledWith('a'); + expect(createElementSpy).not.toHaveBeenCalledWith('a'); }); it('should convert dataURL to Blob correctly', async () => { diff --git a/src/index.css b/src/index.css index 71673a1..c24a203 100644 --- a/src/index.css +++ b/src/index.css @@ -15,6 +15,32 @@ -moz-osx-font-smoothing: grayscale; } +@theme { + --animate-fade-in-scale: fade-in-scale 0.3s ease-out; + --animate-fade-out-scale: fade-out-scale 0.3s ease-out; + + @keyframes fade-out-scale { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.95); + } + } + @keyframes fade-in-scale { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } + } +} + @layer base { body { margin: 0; @@ -27,4 +53,9 @@ button { cursor: pointer; } + + img { + user-select: none; + pointer-events: none; + } } diff --git a/src/shared/lib/__tests__/file-download.test.ts b/src/shared/lib/__tests__/file-download.test.ts new file mode 100644 index 0000000..87789d7 --- /dev/null +++ b/src/shared/lib/__tests__/file-download.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fileDownloadDirect } from '../file-download'; + +describe('fileDownloadDirect', () => { + let createElementSpy: ReturnType; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let mockLink: HTMLAnchorElement; + let clickSpy: ReturnType; + let removeSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Мокируем методы элемента + clickSpy = vi.fn(); + removeSpy = vi.fn(); + + mockLink = { + download: '', + href: '', + click: clickSpy, + remove: removeSpy, + } as unknown as HTMLAnchorElement; + + // Мокируем document.createElement + createElementSpy = vi + .spyOn(document, 'createElement') + .mockReturnValue(mockLink); + + // Мокируем URL методы + createObjectURLSpy = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:http://localhost/test-blob-url'); + revokeObjectURLSpy = vi + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('должна создавать элемент для скачивания', () => { + const blob = new Blob(['test content'], { type: 'text/plain' }); + const filename = 'test.txt'; + + fileDownloadDirect(blob, filename); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + }); + + it('должна устанавливать правильное имя файла', () => { + const blob = new Blob(['test content'], { type: 'text/plain' }); + const filename = 'my-file.txt'; + + fileDownloadDirect(blob, filename); + + expect(mockLink.download).toBe('my-file.txt'); + }); + + it('должна создавать blob URL и устанавливать его в href', () => { + const blob = new Blob(['test content'], { type: 'image/jpeg' }); + const filename = 'image.jpg'; + + fileDownloadDirect(blob, filename); + + expect(createObjectURLSpy).toHaveBeenCalledWith(blob); + expect(mockLink.href).toBe('blob:http://localhost/test-blob-url'); + }); + + it('должна вызывать click() на элементе', () => { + const blob = new Blob(['test content'], { type: 'text/plain' }); + const filename = 'test.txt'; + + fileDownloadDirect(blob, filename); + + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('должна удалять элемент после клика', () => { + const blob = new Blob(['test content'], { type: 'text/plain' }); + const filename = 'test.txt'; + + fileDownloadDirect(blob, filename); + + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it('должна отзывать blob URL после использования', () => { + const blob = new Blob(['test content'], { type: 'text/plain' }); + const filename = 'test.txt'; + const blobUrl = 'blob:http://localhost/test-blob-url'; + + fileDownloadDirect(blob, filename); + + expect(revokeObjectURLSpy).toHaveBeenCalledWith(blobUrl); + }); + + it('должна работать с разными типами файлов', () => { + const imageBlob = new Blob(['image data'], { type: 'image/png' }); + const pdfBlob = new Blob(['pdf data'], { type: 'application/pdf' }); + + fileDownloadDirect(imageBlob, 'image.png'); + expect(mockLink.download).toBe('image.png'); + + fileDownloadDirect(pdfBlob, 'document.pdf'); + expect(mockLink.download).toBe('document.pdf'); + }); + + it('должна работать с разными именами файлов', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + + fileDownloadDirect(blob, 'simple.txt'); + expect(mockLink.download).toBe('simple.txt'); + + fileDownloadDirect(blob, 'file-with-dashes.txt'); + expect(mockLink.download).toBe('file-with-dashes.txt'); + + fileDownloadDirect(blob, 'file.with.dots.txt'); + expect(mockLink.download).toBe('file.with.dots.txt'); + }); + + it('должна выполнять все необходимые операции', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + const filename = 'test.txt'; + + fileDownloadDirect(blob, filename); + + // Проверяем, что все методы были вызваны + expect(createElementSpy).toHaveBeenCalled(); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/shared/lib/__tests__/load-image.test.ts b/src/shared/lib/__tests__/load-image.test.ts new file mode 100644 index 0000000..88e5451 --- /dev/null +++ b/src/shared/lib/__tests__/load-image.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadImageFromUrl } from '../load-image'; + +describe('loadImageFromUrl', () => { + interface ImageMockInstance { + onload: (() => void) | null; + onerror: ((error: Event | string) => void) | null; + naturalWidth: number; + naturalHeight: number; + } + + let ImageMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Создаем мок Image + ImageMock = vi.fn(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + vi.stubGlobal('Image', ImageMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('должна успешно загружать изображение', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 1920; + this.naturalHeight = 1080; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const url = 'https://example.com/image.jpg'; + const result = await loadImageFromUrl(url); + + expect(result).toBeInstanceOf(Image); + expect(result.naturalWidth).toBe(1920); + expect(result.naturalHeight).toBe(1080); + }); + + it('должна вызывать onRevokeUrl при успешной загрузке', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 800; + this.naturalHeight = 600; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const revokeUrlSpy = vi.fn(); + const url = 'blob:http://localhost/test'; + + await loadImageFromUrl(url, revokeUrlSpy); + + expect(revokeUrlSpy).toHaveBeenCalledTimes(1); + }); + + it('должна вызывать onRevokeUrl при ошибке загрузки', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onerror) { + const errorEvent = new Event('error') as ErrorEvent; + this.onerror(errorEvent); + } + }, 0); + }, + }); + }); + + const revokeUrlSpy = vi.fn(); + const url = 'blob:http://localhost/test'; + + await expect(loadImageFromUrl(url, revokeUrlSpy)).rejects.toThrow( + 'Failed to load image', + ); + + expect(revokeUrlSpy).toHaveBeenCalledTimes(1); + }); + + it('должна выбрасывать ошибку при неудачной загрузке', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onerror) { + const errorEvent = new Event('error') as ErrorEvent; + this.onerror(errorEvent); + } + }, 0); + }, + }); + }); + + const url = 'https://example.com/nonexistent.jpg'; + + await expect(loadImageFromUrl(url)).rejects.toThrow('Failed to load image'); + }); + + it('должна работать без onRevokeUrl колбэка', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 500; + this.naturalHeight = 500; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const url = 'https://example.com/image.jpg'; + const result = await loadImageFromUrl(url); + + expect(result).toBeInstanceOf(Image); + expect(result.naturalWidth).toBe(500); + expect(result.naturalHeight).toBe(500); + }); + + it('должна устанавливать src изображения', async () => { + let setSrcValue = ''; + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 100; + this.naturalHeight = 100; + Object.defineProperty(this, 'src', { + set(value: string) { + setSrcValue = value; + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + get() { + return setSrcValue; + }, + }); + }); + + const url = 'https://example.com/test-image.png'; + await loadImageFromUrl(url); + + expect(setSrcValue).toBe(url); + }); + + it('должна обрабатывать разные типы URL', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 200; + this.naturalHeight = 200; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const blobUrl = 'blob:http://localhost/test'; + const httpUrl = 'http://example.com/image.jpg'; + const httpsUrl = 'https://example.com/image.jpg'; + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + const blobResult = await loadImageFromUrl(blobUrl); + expect(blobResult).toBeInstanceOf(Image); + + const httpResult = await loadImageFromUrl(httpUrl); + expect(httpResult).toBeInstanceOf(Image); + + const httpsResult = await loadImageFromUrl(httpsUrl); + expect(httpsResult).toBeInstanceOf(Image); + + const dataResult = await loadImageFromUrl(dataUrl); + expect(dataResult).toBeInstanceOf(Image); + }); +}); diff --git a/src/shared/lib/dialog-Instance.ts b/src/shared/lib/dialog-Instance.ts new file mode 100644 index 0000000..fd805aa --- /dev/null +++ b/src/shared/lib/dialog-Instance.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createFactory } from '@withease/factories'; +import { + type Effect, + type EventCallable, + createEvent, + createStore, + sample, +} from 'effector'; +import { delay } from 'patronum'; + +export const createDialogInstance = createFactory(creator); + +interface Creator { + onAfterClosed?: + | EventCallable + | EventCallable[] + | Effect + | Effect[]; + trigger?: + | EventCallable + | EventCallable[] + | Effect + | Effect[]; + isOpened?: boolean; +} + +function creator(props: Creator = {}) { + const { isOpened = false, trigger, onAfterClosed } = props; + + const open = createEvent(); + const close = createEvent(); + const toggle = createEvent(); + const afterClosed = delay(close, 200); + + const $isOpened = createStore(isOpened) + .on(open, () => true) + .on(toggle, isOpened => !isOpened) + .reset(close); + + if (trigger) { + sample({ + clock: Array.isArray(trigger) ? trigger : [trigger], + fn: () => true, + target: $isOpened, + }); + } + if (onAfterClosed) { + sample({ + clock: afterClosed, + target: Array.isArray(onAfterClosed) ? onAfterClosed : [onAfterClosed], + }); + } + + return { + open, + close, + toggle, + afterClosed, + $isOpened, + }; +} diff --git a/src/shared/lib/file-download.ts b/src/shared/lib/file-download.ts new file mode 100644 index 0000000..2145f8d --- /dev/null +++ b/src/shared/lib/file-download.ts @@ -0,0 +1,13 @@ +/** + * Скачивает файл напрямую без диалога выбора места сохранения + * @param blob - Blob файла для скачивания + * @param filename - Имя файла для скачивания + */ +export function fileDownloadDirect(blob: Blob, filename: string): void { + const link = document.createElement('a'); + link.download = filename; + link.href = URL.createObjectURL(blob); + link.click(); + link.remove(); + URL.revokeObjectURL(link.href); +} diff --git a/src/shared/lib/load-image.ts b/src/shared/lib/load-image.ts new file mode 100644 index 0000000..ef817e8 --- /dev/null +++ b/src/shared/lib/load-image.ts @@ -0,0 +1,30 @@ +/** + * Загружает изображение по URL и возвращает Promise с HTMLImageElement. + * @param url - URL изображения + * @param onRevokeUrl - Опциональный колбэк для освобождения ресурсов (например, blob URL) + * @returns Promise с загруженным изображением + */ +export function loadImageFromUrl( + url: string, + onRevokeUrl?: () => void, +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + if (onRevokeUrl) { + onRevokeUrl(); + } + resolve(img); + }; + + img.onerror = () => { + if (onRevokeUrl) { + onRevokeUrl(); + } + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); +} diff --git a/src/shared/ui/button/index.ts b/src/shared/ui/button/index.ts new file mode 100644 index 0000000..1b3c199 --- /dev/null +++ b/src/shared/ui/button/index.ts @@ -0,0 +1 @@ +export { Button } from './view'; diff --git a/src/shared/ui/button.tsx b/src/shared/ui/button/view.tsx similarity index 100% rename from src/shared/ui/button.tsx rename to src/shared/ui/button/view.tsx diff --git a/src/shared/ui/dialog/dialog-backdrop.tsx b/src/shared/ui/dialog/dialog-backdrop.tsx new file mode 100644 index 0000000..89aed91 --- /dev/null +++ b/src/shared/ui/dialog/dialog-backdrop.tsx @@ -0,0 +1,27 @@ +import { cn } from '~/shared/lib/cn'; + +import { useDialogClose } from './dialog-context'; + +interface DialogBackdropProps { + className?: string; + onClick?: () => void; +} + +export function DialogBackdrop({ onClick, className }: DialogBackdropProps) { + const close = useDialogClose(); + + const handleClick = () => { + if (onClick) { + onClick(); + } else { + close(); + } + }; + + return ( +
+ ); +} diff --git a/src/shared/ui/dialog/dialog-close-button.tsx b/src/shared/ui/dialog/dialog-close-button.tsx new file mode 100644 index 0000000..412d8da --- /dev/null +++ b/src/shared/ui/dialog/dialog-close-button.tsx @@ -0,0 +1,27 @@ +import { X } from 'lucide-react'; + +import { cn } from '~/shared/lib/cn'; + +import { useDialogClose } from './dialog-context'; + +interface DialogCloseButtonProps { + className?: string; +} + +export function DialogCloseButton({ className }: DialogCloseButtonProps) { + const onClose = useDialogClose(); + + return ( + + ); +} diff --git a/src/shared/ui/dialog/dialog-content.tsx b/src/shared/ui/dialog/dialog-content.tsx new file mode 100644 index 0000000..d5eac8b --- /dev/null +++ b/src/shared/ui/dialog/dialog-content.tsx @@ -0,0 +1,19 @@ +import { cn } from '~/shared/lib/cn'; + +interface DialogContentProps { + className?: string; + children: React.ReactNode; +} + +export function DialogContent({ children, className }: DialogContentProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/shared/ui/dialog/dialog-context.tsx b/src/shared/ui/dialog/dialog-context.tsx new file mode 100644 index 0000000..cc10942 --- /dev/null +++ b/src/shared/ui/dialog/dialog-context.tsx @@ -0,0 +1,57 @@ +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useId } from 'react'; + +interface DialogContextValue { + open: boolean; + onClose: () => void; + titleId: string; + dialogRef: React.RefObject; +} + +const DialogContext = createContext(null); + +export function useDialogContext() { + const context = useContext(DialogContext); + if (!context) { + throw new Error('Dialog components must be used within a Dialog component'); + } + return context; +} + +/** + * Хук для получения состояния открытия диалога + */ +export function useDialogOpen() { + const { open } = useDialogContext(); + return open; +} + +/** + * Хук для получения функции закрытия диалога + */ +export function useDialogClose() { + const { onClose } = useDialogContext(); + return onClose; +} + +interface DialogProviderProps { + children: React.ReactNode; + open: boolean; + onClose: () => void; + dialogRef: React.RefObject; +} + +export function DialogProvider({ + children, + open, + onClose, + dialogRef, +}: DialogProviderProps) { + const titleId = useId(); + + return ( + + {children} + + ); +} diff --git a/src/shared/ui/dialog/dialog-header.tsx b/src/shared/ui/dialog/dialog-header.tsx new file mode 100644 index 0000000..45289e5 --- /dev/null +++ b/src/shared/ui/dialog/dialog-header.tsx @@ -0,0 +1,19 @@ +import { useDialogContext } from './dialog-context'; + +interface DialogHeaderProps { + title?: string; +} + +export function DialogHeader({ title }: DialogHeaderProps) { + const { titleId } = useDialogContext(); + + if (!title) { + return null; + } + + return ( +

+ {title} +

+ ); +} diff --git a/src/shared/ui/dialog/dialog.tsx b/src/shared/ui/dialog/dialog.tsx new file mode 100644 index 0000000..07f3b3f --- /dev/null +++ b/src/shared/ui/dialog/dialog.tsx @@ -0,0 +1,106 @@ +import { useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import { cn } from '~/shared/lib/cn'; + +import { DialogBackdrop } from './dialog-backdrop'; +import { DialogContent } from './dialog-content'; +import { DialogProvider, useDialogContext } from './dialog-context'; +import { + useBodyScrollLock, + useEscapeKey, + useFocusTrap, + useInitialFocus, +} from './hooks'; + +export interface DialogProps { + open: boolean; + className?: string; + onClose: () => void; + children?: React.ReactNode; + onBackdropClick?: () => void; +} + +/** + * Компонент для применения эффектов диалога (хуки) + * Должен быть внутри DialogProvider для доступа к контексту + */ +function DialogEffects() { + useEscapeKey(); + useBodyScrollLock(); + useInitialFocus(); + useFocusTrap(); + return null; +} + +interface DialogInnerProps { + className?: string; + children: React.ReactNode; + onBackdropClick?: () => void; +} + +function DialogInner(props: DialogInnerProps) { + const { children, className, onBackdropClick } = props; + const { titleId, dialogRef } = useDialogContext(); + + return ( +
+ + + {/* Dialog content */} +
event.stopPropagation()} + className={cn( + 'animate-fade-in-scale relative z-10 max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-2xl border border-zinc-200 bg-white p-0 shadow-2xl duration-200 outline-none', + className, + )} + > + {children} +
+
+ ); +} + +export function Dialog({ + open, + onClose, + children, + onBackdropClick, + className, +}: DialogProps) { + const dialogRef = useRef(null); + + const handleBackdropClick = () => { + if (onBackdropClick) { + onBackdropClick(); + } else { + onClose(); + } + }; + + if (!open) { + return null; + } + + // Проверяем, что document.body существует (для SSR) + if (typeof document === 'undefined' || !document.body) { + return null; + } + + const dialogContent = ( + + + + {children} + + + ); + + // Всегда используем Portal для рендеринга в document.body + // Это гарантирует, что диалог рендерится вне основного DOM дерева + return createPortal(dialogContent, document.body); +} diff --git a/src/shared/ui/dialog/hooks.ts b/src/shared/ui/dialog/hooks.ts new file mode 100644 index 0000000..8460c45 --- /dev/null +++ b/src/shared/ui/dialog/hooks.ts @@ -0,0 +1,124 @@ +import { useEffect, useRef } from 'react'; + +import { useDialogContext } from './dialog-context'; + +/** + * Обработка ESC клавиши для закрытия диалога + * Использует onClose из контекста + */ +export function useEscapeKey() { + const { open, onClose } = useDialogContext(); + + useEffect(() => { + if (!open) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [open, onClose]); +} + +/** + * Блокировка скролла body при открытии модального окна + * и возврат фокуса к предыдущему элементу при закрытии + * Использует open из контекста + */ +export function useBodyScrollLock() { + const { open } = useDialogContext(); + const previousActiveElementRef = useRef(null); + + useEffect(() => { + if (open) { + previousActiveElementRef.current = + (document.activeElement as HTMLElement) || null; + + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + document.body.style.overflow = 'hidden'; + document.body.style.paddingRight = `${scrollbarWidth}px`; + + return () => { + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; + previousActiveElementRef.current?.focus(); + }; + } + }, [open]); +} + +/** + * Установка начального фокуса на первый интерактивный элемент + * Использует dialogRef и open из контекста + */ +export function useInitialFocus() { + const { dialogRef, open } = useDialogContext(); + + useEffect(() => { + if (!open) return; + + const dialog = dialogRef.current; + if (!dialog) return; + + requestAnimationFrame(() => { + const firstFocusable = dialog.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (firstFocusable) { + firstFocusable.focus(); + } + }); + }, [open, dialogRef]); +} + +/** + * Ловушка фокуса - удерживает фокус внутри диалога + * Использует dialogRef и open из контекста + */ +export function useFocusTrap() { + const { dialogRef, open } = useDialogContext(); + + useEffect(() => { + if (!open) return; + + const dialog = dialogRef.current; + if (!dialog) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + const focusableElements = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + }; + + dialog.addEventListener('keydown', handleKeyDown); + return () => { + dialog.removeEventListener('keydown', handleKeyDown); + }; + }, [open, dialogRef]); +} diff --git a/src/shared/ui/dialog/index.ts b/src/shared/ui/dialog/index.ts new file mode 100644 index 0000000..a746535 --- /dev/null +++ b/src/shared/ui/dialog/index.ts @@ -0,0 +1,11 @@ +export { Dialog } from './dialog'; +export type { DialogProps } from './dialog'; +export { DialogBackdrop } from './dialog-backdrop'; +export { DialogContent } from './dialog-content'; +export { DialogCloseButton } from './dialog-close-button'; +export { DialogHeader } from './dialog-header'; +export { + useDialogContext, + useDialogOpen, + useDialogClose, +} from './dialog-context'; diff --git a/src/shared/ui/file-input/index.ts b/src/shared/ui/file-input/index.ts new file mode 100644 index 0000000..ca867f6 --- /dev/null +++ b/src/shared/ui/file-input/index.ts @@ -0,0 +1 @@ +export { FileInput } from './view'; diff --git a/src/shared/ui/file-input/view.tsx b/src/shared/ui/file-input/view.tsx new file mode 100644 index 0000000..3bdcf10 --- /dev/null +++ b/src/shared/ui/file-input/view.tsx @@ -0,0 +1,33 @@ +import { forwardRef, useId } from 'react'; + +interface FileInputProps { + onChange: (file: File) => void; +} + +export const FileInput = forwardRef( + ({ onChange }, ref) => { + const id = useId(); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + onChange(file); + } + + event.target.value = ''; + event.target.files = null; + }; + + return ( + + ); + }, +); +FileInput.displayName = 'UI/FileInput'; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 8a5d5c7..e71be9c 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,3 +1,18 @@ export { Button } from './button'; export { Slider } from './slider'; export { Toggle } from './toggle'; +export { FileInput } from './file-input'; +export { + Dialog, + DialogBackdrop, + DialogContent, + DialogCloseButton, + DialogHeader, + useDialogContext, + useDialogOpen, + useDialogClose, +} from './dialog'; +export type { DialogProps } from './dialog'; + +export { InputNumber } from './input'; +export { Select } from './select'; diff --git a/src/shared/ui/input/index.ts b/src/shared/ui/input/index.ts new file mode 100644 index 0000000..a919f29 --- /dev/null +++ b/src/shared/ui/input/index.ts @@ -0,0 +1 @@ +export { InputNumber } from './input-number'; diff --git a/src/shared/ui/input/input-number.tsx b/src/shared/ui/input/input-number.tsx new file mode 100644 index 0000000..ad7d3b6 --- /dev/null +++ b/src/shared/ui/input/input-number.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from 'react'; + +import { cn } from '~/shared/lib/cn'; + +interface InputNumberProps extends React.InputHTMLAttributes { + className?: string; +} + +export const InputNumber = forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + }, +); + +InputNumber.displayName = 'UI/InputNumberBase'; diff --git a/src/shared/ui/select/index.ts b/src/shared/ui/select/index.ts new file mode 100644 index 0000000..511c1b1 --- /dev/null +++ b/src/shared/ui/select/index.ts @@ -0,0 +1 @@ +export { Select } from './view'; diff --git a/src/shared/ui/select/view.tsx b/src/shared/ui/select/view.tsx new file mode 100644 index 0000000..60f2a79 --- /dev/null +++ b/src/shared/ui/select/view.tsx @@ -0,0 +1,40 @@ +import { forwardRef, useId } from 'react'; + +import { cn } from '~/shared/lib/cn'; + +interface SelectProps extends React.SelectHTMLAttributes { + label: string; + className?: string; +} + +export const Select = forwardRef( + (props, ref) => { + const id = useId(); + const { label, children, className, ...rest } = props; + + return ( +
+ + + +
+ ); + }, +); + +Select.displayName = 'UI/Select'; diff --git a/src/shared/ui/slider/index.ts b/src/shared/ui/slider/index.ts new file mode 100644 index 0000000..d82d697 --- /dev/null +++ b/src/shared/ui/slider/index.ts @@ -0,0 +1 @@ +export { Slider } from './view'; diff --git a/src/shared/ui/slider.tsx b/src/shared/ui/slider/view.tsx similarity index 50% rename from src/shared/ui/slider.tsx rename to src/shared/ui/slider/view.tsx index 42d86ab..05f86c0 100644 --- a/src/shared/ui/slider.tsx +++ b/src/shared/ui/slider/view.tsx @@ -1,5 +1,7 @@ import { forwardRef, useId } from 'react'; +import { cn } from '../../lib/cn'; + interface SliderProps { min: number; max: number; @@ -7,19 +9,31 @@ interface SliderProps { value: number; unit?: string; step?: number; + className?: string; + disabled?: boolean; + onDoubleClick?: () => void; onChange: (value: number) => void; } export const Slider = forwardRef( (props, ref) => { - const { label, value, onChange, unit = '', ...rest } = props; + const { + label, + value, + onChange, + onDoubleClick, + unit = '', + disabled, + className, + ...rest + } = props; const id = useId(); return ( -
+
); diff --git a/src/shared/ui/toggle/index.ts b/src/shared/ui/toggle/index.ts new file mode 100644 index 0000000..d67d3e6 --- /dev/null +++ b/src/shared/ui/toggle/index.ts @@ -0,0 +1 @@ +export { Toggle } from './view'; diff --git a/src/shared/ui/toggle.tsx b/src/shared/ui/toggle/view.tsx similarity index 100% rename from src/shared/ui/toggle.tsx rename to src/shared/ui/toggle/view.tsx diff --git a/src/widgets/header/view.tsx b/src/widgets/header/view.tsx index e4a0e90..96c1649 100644 --- a/src/widgets/header/view.tsx +++ b/src/widgets/header/view.tsx @@ -9,6 +9,8 @@ import { ZoomOutButton, } from '~/features/viewport-control'; +import { Dialog, OpenButton } from '../image-resize-dialog'; + export function Header() { return (
@@ -18,6 +20,7 @@ export function Header() {
+
@@ -32,6 +35,7 @@ export function Header() {
+ ); } diff --git a/src/widgets/image-resize-dialog/__tests__/lib.test.ts b/src/widgets/image-resize-dialog/__tests__/lib.test.ts new file mode 100644 index 0000000..16471df --- /dev/null +++ b/src/widgets/image-resize-dialog/__tests__/lib.test.ts @@ -0,0 +1,1322 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Импортируем для мокирования +import * as loadImageModule from '~/shared/lib/load-image'; +import { fileDownloadDirect } from '~/shared/lib/file-download'; + +import * as libModule from '../lib'; +import { + calculateProportionalDimension, + getImageSizesFromBlob, + getImageSizesFromUrl, + prepareResizeParams, + resizeImageFromUrl, +} from '../lib'; + +// Мокируем зависимости +vi.mock('~/shared/lib/file-download', () => ({ + fileDownloadDirect: vi.fn(), +})); + +describe('getImageSizesFromBlob', () => { + interface ImageMockInstance { + onload: (() => void) | null; + onerror: ((error: Event | string) => void) | null; + naturalWidth: number; + naturalHeight: number; + } + + let mockFile: File; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let ImageMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Создаем мок File объекта + mockFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); + + // Мокируем URL методы + createObjectURLSpy = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:http://localhost/test'); + revokeObjectURLSpy = vi + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}); + + // Создаем общий мок Image + ImageMock = vi.fn(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + vi.stubGlobal('Image', ImageMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('должна возвращать размеры изображения при успешной загрузке', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 1920; + this.naturalHeight = 1080; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const result = await getImageSizesFromBlob(mockFile); + + expect(result).toEqual({ + width: 1920, + height: 1080, + }); + expect(createObjectURLSpy).toHaveBeenCalledWith(mockFile); + expect(revokeObjectURLSpy).toHaveBeenCalledWith( + 'blob:http://localhost/test', + ); + }); + + it('должна обрабатывать изображения разных размеров', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 800; + this.naturalHeight = 600; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const result = await getImageSizesFromBlob(mockFile); + + expect(result).toEqual({ + width: 800, + height: 600, + }); + }); + + it('должна обрабатывать квадратные изображения', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 500; + this.naturalHeight = 500; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const result = await getImageSizesFromBlob(mockFile); + + expect(result).toEqual({ + width: 500, + height: 500, + }); + }); + + it('должна выбрасывать ошибку при неудачной загрузке изображения', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onerror) { + // Создаем Event объект для onerror + const errorEvent = new Event('error') as ErrorEvent; + this.onerror(errorEvent); + } + }, 0); + }, + }); + }); + + await expect(getImageSizesFromBlob(mockFile)).rejects.toThrow( + 'Failed to load image', + ); + expect(revokeObjectURLSpy).toHaveBeenCalled(); + }); + + it('должна освобождать URL даже при ошибке', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onerror) { + // Создаем Event объект для onerror + const errorEvent = new Event('error') as ErrorEvent; + this.onerror(errorEvent); + } + }, 0); + }, + }); + }); + + try { + await getImageSizesFromBlob(mockFile); + } catch { + // Игнорируем ошибку + } + + expect(revokeObjectURLSpy).toHaveBeenCalledWith( + 'blob:http://localhost/test', + ); + }); +}); + +describe('getImageSizesFromUrl', () => { + interface ImageMockInstance { + onload: (() => void) | null; + onerror: ((error: Event | string) => void) | null; + naturalWidth: number; + naturalHeight: number; + } + + let revokeObjectURLSpy: ReturnType; + let ImageMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Мокируем URL методы + revokeObjectURLSpy = vi + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}); + + // Создаем общий мок Image + ImageMock = vi.fn(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + vi.stubGlobal('Image', ImageMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('должна возвращать размеры изображения при успешной загрузке blob URL', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 1920; + this.naturalHeight = 1080; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const blobUrl = 'blob:http://localhost/test'; + const result = await getImageSizesFromUrl(blobUrl); + + expect(result).toEqual({ + width: 1920, + height: 1080, + }); + expect(revokeObjectURLSpy).toHaveBeenCalledWith(blobUrl); + }); + + it('должна возвращать размеры изображения при успешной загрузке обычного URL', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 800; + this.naturalHeight = 600; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const regularUrl = 'https://example.com/image.jpg'; + const result = await getImageSizesFromUrl(regularUrl); + + expect(result).toEqual({ + width: 800, + height: 600, + }); + // Обычный URL не должен освобождаться + expect(revokeObjectURLSpy).not.toHaveBeenCalled(); + }); + + it('должна выбрасывать ошибку при неудачной загрузке', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onerror) { + const errorEvent = new Event('error') as ErrorEvent; + this.onerror(errorEvent); + } + }, 0); + }, + }); + }); + + const blobUrl = 'blob:http://localhost/test'; + await expect(getImageSizesFromUrl(blobUrl)).rejects.toThrow( + 'Failed to load image', + ); + expect(revokeObjectURLSpy).toHaveBeenCalledWith(blobUrl); + }); +}); + +describe('resizeImageFromUrl', () => { + interface ImageMockInstance { + onload: (() => void) | null; + onerror: ((error: Event | string) => void) | null; + naturalWidth: number; + naturalHeight: number; + } + + let ImageMock: ReturnType; + let createElementSpy: ReturnType; + let mockCanvas: HTMLCanvasElement; + let mockContext: CanvasRenderingContext2D; + let toBlobSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Мокируем Image + ImageMock = vi.fn(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 1000; + this.naturalHeight = 800; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + vi.stubGlobal('Image', ImageMock); + + // Мокируем canvas + mockContext = { + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D; + + mockCanvas = { + width: 0, + height: 0, + getContext: vi.fn(() => mockContext), + toBlob: vi.fn(), + } as unknown as HTMLCanvasElement; + + createElementSpy = vi + .spyOn(document, 'createElement') + .mockReturnValue(mockCanvas); + + // Мокируем toBlob + toBlobSpy = vi + .spyOn(mockCanvas, 'toBlob') + .mockImplementation((callback: (blob: Blob | null) => void) => { + const blob = new Blob(['test'], { type: 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('должна загружать изображение и изменять размер на указанный процент', async () => { + const url = 'blob:http://localhost/test'; + const percent = 50; + + const result = await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + }); + + expect(result).toBeInstanceOf(Blob); + expect(result.type).toBe('image/jpeg'); + expect(createElementSpy).toHaveBeenCalledWith('canvas'); + expect(mockCanvas.getContext).toHaveBeenCalledWith('2d'); + expect(mockContext.drawImage).toHaveBeenCalled(); + expect(toBlobSpy).toHaveBeenCalled(); + }); + + it('должна правильно рассчитывать размеры при изменении на 50%', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 1000; + this.naturalHeight = 800; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const url = 'blob:http://localhost/test'; + const percent = 50; + + await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + }); + + // Проверяем, что drawImage был вызван с правильными размерами + expect(mockContext.drawImage).toHaveBeenCalledWith( + expect.anything(), + 0, + 0, + 500, // 1000 * 50 / 100 + 400, // 800 * 50 / 100 + ); + }); + + it('должна правильно рассчитывать размеры при увеличении на 150%', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 800; + this.naturalHeight = 600; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onload) { + this.onload(); + } + }, 0); + }, + }); + }); + + const url = 'blob:http://localhost/test'; + const percent = 150; + + await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + }); + + // Проверяем, что drawImage был вызван с правильными размерами + expect(mockContext.drawImage).toHaveBeenCalledWith( + expect.anything(), + 0, + 0, + 1200, // 800 * 150 / 100 + 900, // 600 * 150 / 100 + ); + }); + + it('должна выбрасывать ошибку при неудачной загрузке изображения', async () => { + ImageMock.mockImplementation(function ImageMock(this: ImageMockInstance) { + this.onload = null; + this.onerror = null; + this.naturalWidth = 0; + this.naturalHeight = 0; + Object.defineProperty(this, 'src', { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + set(_value: string) { + setTimeout(() => { + if (this.onerror) { + const errorEvent = new Event('error') as ErrorEvent; + this.onerror(errorEvent); + } + }, 0); + }, + }); + }); + + const url = 'blob:http://localhost/test'; + const percent = 50; + + await expect( + resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + }), + ).rejects.toThrow('Failed to load image'); + }); + + it('должна выбрасывать ошибку если не удалось получить canvas context', async () => { + mockCanvas.getContext = vi.fn(() => null); + + const url = 'blob:http://localhost/test'; + const percent = 50; + + await expect( + resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + }), + ).rejects.toThrow('Failed to get canvas context'); + }); + + it('должна выбрасывать ошибку если toBlob вернул null', async () => { + toBlobSpy.mockImplementation((callback: (blob: Blob | null) => void) => { + setTimeout(() => callback(null), 0); + }); + + const url = 'blob:http://localhost/test'; + const percent = 50; + + await expect( + resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + }), + ).rejects.toThrow('Failed to create blob from canvas'); + }); + + it('должна использовать указанный формат вывода (PNG)', async () => { + const url = 'blob:http://localhost/test'; + const percent = 50; + const outputFormat = 'image/png'; + + toBlobSpy.mockImplementation( + (callback: (blob: Blob | null) => void, mimeType?: string) => { + const blob = new Blob(['test'], { type: mimeType || 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }, + ); + + const result = await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + outputFormat, + }); + + expect(result.type).toBe('image/png'); + expect(toBlobSpy).toHaveBeenCalledWith( + expect.any(Function), + 'image/png', + undefined, // PNG не поддерживает качество + ); + }); + + it('должна использовать указанный формат вывода (WebP) с качеством', async () => { + const url = 'blob:http://localhost/test'; + const percent = 50; + const outputFormat = 'image/webp'; + const quality = 0.8; + + toBlobSpy.mockImplementation( + (callback: (blob: Blob | null) => void, mimeType?: string) => { + const blob = new Blob(['test'], { type: mimeType || 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }, + ); + + const result = await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + outputFormat, + quality, + }); + + expect(result.type).toBe('image/webp'); + expect(toBlobSpy).toHaveBeenCalledWith( + expect.any(Function), + 'image/webp', + 0.8, + ); + }); + + it('должна использовать указанное качество для JPEG', async () => { + const url = 'blob:http://localhost/test'; + const percent = 50; + const quality = 0.7; + + toBlobSpy.mockImplementation( + (callback: (blob: Blob | null) => void, mimeType?: string) => { + const blob = new Blob(['test'], { type: mimeType || 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }, + ); + + await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + quality, + }); + + expect(toBlobSpy).toHaveBeenCalledWith( + expect.any(Function), + 'image/jpeg', + 0.7, + ); + }); + + it('должна использовать формат и качество для режима dimensions', async () => { + const url = 'blob:http://localhost/test'; + const width = 800; + const height = 600; + const outputFormat = 'image/webp'; + const quality = 0.85; + + toBlobSpy.mockImplementation( + (callback: (blob: Blob | null) => void, mimeType?: string) => { + const blob = new Blob(['test'], { type: mimeType || 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }, + ); + + const result = await resizeImageFromUrl({ + mode: 'dimensions', + imageUrl: url, + width, + height, + outputFormat, + quality, + }); + + expect(result.type).toBe('image/webp'); + expect(toBlobSpy).toHaveBeenCalledWith( + expect.any(Function), + 'image/webp', + 0.85, + ); + expect(mockContext.drawImage).toHaveBeenCalledWith( + expect.anything(), + 0, + 0, + width, + height, + ); + }); + + it('должна игнорировать качество для PNG формата', async () => { + const url = 'blob:http://localhost/test'; + const percent = 50; + const outputFormat = 'image/png'; + const quality = 0.9; // Должно быть проигнорировано для PNG + + toBlobSpy.mockImplementation( + (callback: (blob: Blob | null) => void, mimeType?: string) => { + const blob = new Blob(['test'], { type: mimeType || 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }, + ); + + await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + outputFormat, + quality, + }); + + // PNG не поддерживает качество, должно быть undefined + expect(toBlobSpy).toHaveBeenCalledWith( + expect.any(Function), + 'image/png', + undefined, + ); + }); + + it('должна использовать качество по умолчанию если не указано', async () => { + const url = 'blob:http://localhost/test'; + const percent = 50; + const outputFormat = 'image/jpeg'; + + toBlobSpy.mockImplementation( + (callback: (blob: Blob | null) => void, mimeType?: string) => { + const blob = new Blob(['test'], { type: mimeType || 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }, + ); + + await resizeImageFromUrl({ + mode: 'percent', + imageUrl: url, + percent, + outputFormat, + }); + + // Должно использоваться качество по умолчанию (1.0) + expect(toBlobSpy).toHaveBeenCalledWith( + expect.any(Function), + 'image/jpeg', + 1.0, + ); + }); +}); + +describe('calculateProportionalDimension', () => { + describe('изменение ширины (вычисление высоты)', () => { + it('должна вычислять высоту при изменении ширины для landscape изображения', () => { + const originalWidth = 1920; + const originalHeight = 1080; + const newWidth = 960; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 960 / (1920 / 1080) = 960 / 1.777... = 540 + expect(result).toBe(540); + }); + + it('должна вычислять высоту при изменении ширины для portrait изображения', () => { + const originalWidth = 800; + const originalHeight = 1200; + const newWidth = 400; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 400 / (800 / 1200) = 400 / 0.666... = 600 + expect(result).toBe(600); + }); + + it('должна вычислять высоту для квадратного изображения', () => { + const originalWidth = 1000; + const originalHeight = 1000; + const newWidth = 500; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 500 / (1000 / 1000) = 500 / 1 = 500 + expect(result).toBe(500); + }); + + it('должна округлять результат до целого числа', () => { + const originalWidth = 1000; + const originalHeight = 750; + const newWidth = 333; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 333 / (1000 / 750) = 333 / 1.333... = 249.75 -> 250 + expect(result).toBe(250); + expect(Number.isInteger(result)).toBe(true); + }); + }); + + describe('изменение высоты (вычисление ширины)', () => { + it('должна вычислять ширину при изменении высоты для landscape изображения', () => { + const originalWidth = 1920; + const originalHeight = 1080; + const newHeight = 540; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newHeight, + 'height', + ); + + // 540 * (1920 / 1080) = 540 * 1.777... = 960 + expect(result).toBe(960); + }); + + it('должна вычислять ширину при изменении высоты для portrait изображения', () => { + const originalWidth = 800; + const originalHeight = 1200; + const newHeight = 600; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newHeight, + 'height', + ); + + // 600 * (800 / 1200) = 600 * 0.666... = 400 + expect(result).toBe(400); + }); + + it('должна вычислять ширину для квадратного изображения', () => { + const originalWidth = 1000; + const originalHeight = 1000; + const newHeight = 500; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newHeight, + 'height', + ); + + // 500 * (1000 / 1000) = 500 * 1 = 500 + expect(result).toBe(500); + }); + + it('должна округлять результат до целого числа', () => { + const originalWidth = 1000; + const originalHeight = 750; + const newHeight = 250; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newHeight, + 'height', + ); + + // 250 * (1000 / 750) = 250 * 1.333... = 333.33... -> 333 + expect(result).toBe(333); + expect(Number.isInteger(result)).toBe(true); + }); + }); + + describe('граничные случаи', () => { + it('должна возвращать changedValue если originalWidth равен 0', () => { + const originalWidth = 0; + const originalHeight = 1080; + const changedValue = 500; + + const resultWidth = calculateProportionalDimension( + originalWidth, + originalHeight, + changedValue, + 'width', + ); + const resultHeight = calculateProportionalDimension( + originalWidth, + originalHeight, + changedValue, + 'height', + ); + + expect(resultWidth).toBe(changedValue); + expect(resultHeight).toBe(changedValue); + }); + + it('должна возвращать changedValue если originalHeight равен 0', () => { + const originalWidth = 1920; + const originalHeight = 0; + const changedValue = 500; + + const resultWidth = calculateProportionalDimension( + originalWidth, + originalHeight, + changedValue, + 'width', + ); + const resultHeight = calculateProportionalDimension( + originalWidth, + originalHeight, + changedValue, + 'height', + ); + + expect(resultWidth).toBe(changedValue); + expect(resultHeight).toBe(changedValue); + }); + + it('должна возвращать changedValue если оба размера равны 0', () => { + const originalWidth = 0; + const originalHeight = 0; + const changedValue = 500; + + const resultWidth = calculateProportionalDimension( + originalWidth, + originalHeight, + changedValue, + 'width', + ); + const resultHeight = calculateProportionalDimension( + originalWidth, + originalHeight, + changedValue, + 'height', + ); + + expect(resultWidth).toBe(changedValue); + expect(resultHeight).toBe(changedValue); + }); + }); + + describe('различные пропорции', () => { + it('должна корректно работать с очень широким изображением (16:9)', () => { + const originalWidth = 3840; + const originalHeight = 2160; + const newWidth = 1920; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 1920 / (3840 / 2160) = 1920 / 1.777... = 1080 + expect(result).toBe(1080); + }); + + it('должна корректно работать с очень высоким изображением (9:16)', () => { + const originalWidth = 1080; + const originalHeight = 1920; + const newHeight = 960; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newHeight, + 'height', + ); + + // 960 * (1080 / 1920) = 960 * 0.5625 = 540 + expect(result).toBe(540); + }); + + it('должна корректно работать с небольшими значениями', () => { + const originalWidth = 100; + const originalHeight = 75; + const newWidth = 50; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 50 / (100 / 75) = 50 / 1.333... = 37.5 -> 38 + expect(result).toBe(38); + }); + + it('должна корректно работать с большими значениями', () => { + const originalWidth = 10000; + const originalHeight = 5000; + const newWidth = 5000; + + const result = calculateProportionalDimension( + originalWidth, + originalHeight, + newWidth, + 'width', + ); + + // 5000 / (10000 / 5000) = 5000 / 2 = 2500 + expect(result).toBe(2500); + }); + }); +}); + +describe('prepareResizeParams', () => { + it('должна возвращать параметры для режима процентов', () => { + const imageUrl = 'blob:http://localhost/test'; + const mode = 'percent'; + const percent = 150; + const width = 1920; + const height = 1080; + + const result = prepareResizeParams(imageUrl, mode, percent, width, height); + + expect(result).toEqual({ + mode: 'percent', + imageUrl, + percent, + }); + }); + + it('должна возвращать параметры для режима размеров', () => { + const imageUrl = 'blob:http://localhost/test'; + const mode = 'dimensions'; + const percent = 100; + const width = 1920; + const height = 1080; + + const result = prepareResizeParams(imageUrl, mode, percent, width, height); + + expect(result).toEqual({ + mode: 'dimensions', + imageUrl, + width, + height, + }); + }); + + it('должна игнорировать percent и width/height в зависимости от режима', () => { + const imageUrl = 'https://example.com/image.jpg'; + const percent = 50; + const width = 800; + const height = 600; + + const percentResult = prepareResizeParams( + imageUrl, + 'percent', + percent, + width, + height, + ); + const dimensionsResult = prepareResizeParams( + imageUrl, + 'dimensions', + percent, + width, + height, + ); + + expect(percentResult).toEqual({ + mode: 'percent', + imageUrl, + percent: 50, + }); + expect(percentResult).not.toHaveProperty('width'); + expect(percentResult).not.toHaveProperty('height'); + + expect(dimensionsResult).toEqual({ + mode: 'dimensions', + imageUrl, + width: 800, + height: 600, + }); + expect(dimensionsResult).not.toHaveProperty('percent'); + }); + + it('должна корректно работать с различными URL', () => { + const blobUrl = 'blob:http://localhost/test'; + const httpUrl = 'https://example.com/image.jpg'; + const dataUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; + + const blobResult = prepareResizeParams(blobUrl, 'percent', 100, 0, 0); + const httpResult = prepareResizeParams( + httpUrl, + 'dimensions', + 0, + 1920, + 1080, + ); + const dataResult = prepareResizeParams(dataUrl, 'percent', 75, 0, 0); + + expect(blobResult.imageUrl).toBe(blobUrl); + expect(httpResult.imageUrl).toBe(httpUrl); + expect(dataResult.imageUrl).toBe(dataUrl); + }); + + it('должна включать outputFormat и quality в параметры для режима процентов', () => { + const imageUrl = 'blob:http://localhost/test'; + const mode = 'percent'; + const percent = 150; + const width = 1920; + const height = 1080; + const outputFormat = 'image/png'; + const quality = 0.8; + + const result = prepareResizeParams( + imageUrl, + mode, + percent, + width, + height, + outputFormat, + quality, + ); + + expect(result).toEqual({ + mode: 'percent', + imageUrl, + percent, + outputFormat, + quality, + }); + }); + + it('должна включать outputFormat и quality в параметры для режима размеров', () => { + const imageUrl = 'blob:http://localhost/test'; + const mode = 'dimensions'; + const percent = 100; + const width = 1920; + const height = 1080; + const outputFormat = 'image/webp'; + const quality = 0.9; + + const result = prepareResizeParams( + imageUrl, + mode, + percent, + width, + height, + outputFormat, + quality, + ); + + expect(result).toEqual({ + mode: 'dimensions', + imageUrl, + width, + height, + outputFormat, + quality, + }); + }); + + it('должна работать без outputFormat и quality (обратная совместимость)', () => { + const imageUrl = 'blob:http://localhost/test'; + const mode = 'percent'; + const percent = 150; + const width = 1920; + const height = 1080; + + const result = prepareResizeParams(imageUrl, mode, percent, width, height); + + expect(result).toEqual({ + mode: 'percent', + imageUrl, + percent, + outputFormat: undefined, + quality: undefined, + }); + // Проверяем, что свойства есть, но undefined (опциональные параметры) + expect('outputFormat' in result).toBe(true); + expect('quality' in result).toBe(true); + expect(result.outputFormat).toBeUndefined(); + expect(result.quality).toBeUndefined(); + }); +}); + +describe('downloadResizedImage - формирование имени файла', () => { + let mockFileDownloadDirect: ReturnType; + let mockImage: HTMLImageElement; + let mockContext: CanvasRenderingContext2D; + let mockCanvas: HTMLCanvasElement; + let toBlobSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockFileDownloadDirect = vi.mocked(fileDownloadDirect); + + // Создаем мок изображения + mockImage = { + naturalWidth: 1000, + naturalHeight: 800, + } as HTMLImageElement; + + // Мокируем loadImageFromUrl + vi.spyOn(loadImageModule, 'loadImageFromUrl').mockResolvedValue(mockImage); + + // Мокируем canvas + mockContext = { + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D; + + mockCanvas = { + width: 0, + height: 0, + getContext: vi.fn(() => mockContext), + toBlob: vi.fn(), + } as unknown as HTMLCanvasElement; + + vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); + + toBlobSpy = vi + .spyOn(mockCanvas, 'toBlob') + .mockImplementation((callback: (blob: Blob | null) => void) => { + const blob = new Blob(['test'], { type: 'image/jpeg' }); + setTimeout(() => callback(blob), 0); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('должен формировать имя файла с расширением из формата вывода', async () => { + const mockFile = new File(['test'], 'photo.png', { type: 'image/png' }); + const mockBlob = new Blob(['test'], { type: 'image/png' }); + + toBlobSpy.mockImplementation((callback: (blob: Blob | null) => void) => { + setTimeout(() => callback(mockBlob), 0); + }); + + await libModule.downloadResizedImage({ + params: { + mode: 'percent', + imageUrl: 'blob:test', + percent: 100, + }, + originalFile: mockFile, + }); + + expect(mockFileDownloadDirect).toHaveBeenCalledTimes(1); + const [, filename] = mockFileDownloadDirect.mock.calls[0]; + + // Используется расширение из blob (png) + expect(filename).toBe('photo_resized.png'); + }); + + it('должен формировать имя файла для файла без расширения', async () => { + const mockFile = new File(['test'], 'image', { type: 'image/jpeg' }); + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + + toBlobSpy.mockImplementation((callback: (blob: Blob | null) => void) => { + setTimeout(() => callback(mockBlob), 0); + }); + + await libModule.downloadResizedImage({ + params: { + mode: 'percent', + imageUrl: 'blob:test', + percent: 100, + }, + originalFile: mockFile, + }); + + expect(mockFileDownloadDirect).toHaveBeenCalledTimes(1); + const [, filename] = mockFileDownloadDirect.mock.calls[0]; + + expect(filename).toBe('image_resized.jpg'); + }); + + it('должен использовать resized-image.extension если originalFile null', async () => { + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + + toBlobSpy.mockImplementation((callback: (blob: Blob | null) => void) => { + setTimeout(() => callback(mockBlob), 0); + }); + + await libModule.downloadResizedImage({ + params: { + mode: 'percent', + imageUrl: 'blob:test', + percent: 100, + }, + originalFile: null, + }); + + expect(mockFileDownloadDirect).toHaveBeenCalledTimes(1); + const [, filename] = mockFileDownloadDirect.mock.calls[0]; + + expect(filename).toBe('resized-image.jpg'); + }); + + it('должен использовать расширение из формата вывода', async () => { + const mockFile = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + const mockBlob = new Blob(['test'], { type: 'image/webp' }); // Формат вывода WebP + + toBlobSpy.mockImplementation((callback: (blob: Blob | null) => void) => { + setTimeout(() => callback(mockBlob), 0); + }); + + await libModule.downloadResizedImage({ + params: { + mode: 'percent', + imageUrl: 'blob:test', + percent: 100, + outputFormat: 'image/webp', + }, + originalFile: mockFile, + }); + + expect(mockFileDownloadDirect).toHaveBeenCalledTimes(1); + const [, filename] = mockFileDownloadDirect.mock.calls[0]; + + // Расширение должно быть из формата вывода (.webp) + expect(filename).toBe('photo_resized.webp'); + }); +}); diff --git a/src/widgets/image-resize-dialog/__tests__/model.test.ts b/src/widgets/image-resize-dialog/__tests__/model.test.ts new file mode 100644 index 0000000..7635148 --- /dev/null +++ b/src/widgets/image-resize-dialog/__tests__/model.test.ts @@ -0,0 +1,251 @@ +import { allSettled, fork } from 'effector'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { downloadResizedImage } from '../lib'; +import { + $image, + $outputFormat, + $quality, + $resizeMode, + $resizePercent, + $resizedHeight, + $resizedWidth, + _$imageRaw, + _downloadResizedImageFx, + _imageUploadFx, + downloadResizedImageClicked, + imageUploadStarted, +} from '../model'; + +// Мокируем функцию скачивания +vi.mock('../lib', async () => { + const actual = await vi.importActual('../lib'); + return { + ...actual, + downloadResizedImage: vi.fn(), + }; +}); + +describe('downloadResizedImageFx', () => { + let mockDownloadResizedImage: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockDownloadResizedImage = vi.mocked(downloadResizedImage); + mockDownloadResizedImage.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('должен вызывать downloadResizedImage с правильными параметрами при режиме percent', async () => { + const mockFile = new File(['test'], 'my-image.jpg', { + type: 'image/jpeg', + }); + const imageUrl = 'blob:http://localhost/test'; + + const downloadResizedImageFn = vi.fn().mockResolvedValue(undefined); + const imageUploadFxFn = vi.fn().mockResolvedValue(imageUrl); + + const scope = fork({ + values: [ + [_$imageRaw, mockFile], + [$resizeMode, 'percent'], + [$resizePercent, 150], + [$resizedWidth, 0], + [$resizedHeight, 0], + [$outputFormat, 'original'], + [$quality, 1.0], + ], + handlers: [ + [_imageUploadFx, imageUploadFxFn], + [_downloadResizedImageFx, downloadResizedImageFn], + ], + }); + + // Загружаем файл (это установит $imageRaw через imageUploadStarted) + await allSettled(_imageUploadFx, { scope, params: mockFile }); + expect(imageUploadFxFn).toHaveBeenCalledTimes(1); + + // Триггерим скачивание + await allSettled(downloadResizedImageClicked, { scope }); + + expect(downloadResizedImageFn).toHaveBeenCalledTimes(1); + const callArgs = downloadResizedImageFn.mock.calls[0][0]; + + expect(callArgs.originalFile).toBe(mockFile); + expect(callArgs.params.mode).toBe('percent'); + expect(callArgs.params.percent).toBe(150); + expect(callArgs.params.imageUrl).toBe(imageUrl); + expect(callArgs.params.outputFormat).toBe('image/jpeg'); // 'original' преобразуется в undefined + }); + + it.todo('должен передавать originalFile из $imageRaw', async () => { + const mockFile = new File(['test'], 'photo.png', { type: 'image/png' }); + const imageUrl = 'blob:http://localhost/test'; + + const downloadResizedImageFn = vi.fn().mockResolvedValue(undefined); + + const scope = fork({ + values: [ + [$image, imageUrl], + [$resizeMode, 'percent'], + [$resizePercent, 100], + [$resizedWidth, 0], + [$resizedHeight, 0], + [$outputFormat, 'original'], + [$quality, 1.0], + ], + handlers: [ + [_imageUploadFx, async () => imageUrl], + [_downloadResizedImageFx, downloadResizedImageFn], + ], + }); + + // await allSettled(imageUploadStarted, { scope, params: mockFile }); + await allSettled(_imageUploadFx, { scope, params: mockFile }); + await allSettled(downloadResizedImageClicked, { scope }); + + expect(mockDownloadResizedImage).toHaveBeenCalledTimes(1); + const callArgs = mockDownloadResizedImage.mock.calls[0][0]; + + // Проверяем, что originalFile передается + expect(callArgs.originalFile).toBe(mockFile); + expect(callArgs.originalFile?.name).toBe('photo.png'); + }); + + it.todo('должен передавать outputFormat в параметрах', async () => { + const mockFile = new File(['test'], 'image.jpg', { type: 'image/jpeg' }); + const imageUrl = 'blob:http://localhost/test'; + + const scope = fork({ + values: [ + [$image, imageUrl], + [$resizeMode, 'percent'], + [$resizePercent, 100], + [$resizedWidth, 0], + [$resizedHeight, 0], + [$outputFormat, 'image/webp'], + [$quality, 1.0], + ], + handlers: [[_imageUploadFx, async () => imageUrl]], + }); + + await allSettled(imageUploadStarted, { scope, params: mockFile }); + await allSettled(_imageUploadFx, { scope, params: mockFile }); + await allSettled(downloadResizedImageClicked, { scope }); + + expect(mockDownloadResizedImage).toHaveBeenCalledTimes(1); + const callArgs = mockDownloadResizedImage.mock.calls[0][0]; + + expect(callArgs.params.outputFormat).toBe('image/webp'); + expect(callArgs.originalFile).toBe(mockFile); + }); + + it.todo('должен передавать quality в параметрах', async () => { + const mockFile = new File(['test'], 'image.jpg', { type: 'image/jpeg' }); + const imageUrl = 'blob:http://localhost/test'; + + const scope = fork({ + values: [ + [$image, imageUrl], + [$resizeMode, 'percent'], + [$resizePercent, 100], + [$resizedWidth, 0], + [$resizedHeight, 0], + [$outputFormat, 'original'], + [$quality, 0.8], + ], + handlers: [[_imageUploadFx, async () => imageUrl]], + }); + + await allSettled(imageUploadStarted, { scope, params: mockFile }); + await allSettled(_imageUploadFx, { scope, params: mockFile }); + await allSettled(downloadResizedImageClicked, { scope }); + + expect(mockDownloadResizedImage).toHaveBeenCalledTimes(1); + const callArgs = mockDownloadResizedImage.mock.calls[0][0]; + + expect(callArgs.params.quality).toBe(0.8); + expect(callArgs.originalFile).toBe(mockFile); + }); + + it.todo('должен передавать параметры для режима dimensions', async () => { + const mockFile = new File(['test'], 'photo.png', { type: 'image/png' }); + const imageUrl = 'blob:http://localhost/test'; + + const fxMock = vi.fn().mockImplementation(async () => imageUrl); + fxMock.mockResolvedValue(imageUrl); + + const scope = fork({ + values: [ + [$image, imageUrl], + [$resizeMode, 'dimensions'], + [$resizePercent, 100], + [$resizedWidth, 800], + [$resizedHeight, 600], + [$outputFormat, 'original'], + [$quality, 1.0], + ], + handlers: [[_imageUploadFx, fxMock]], + }); + + await allSettled(imageUploadStarted, { scope, params: mockFile }); + await allSettled(_imageUploadFx, { scope, params: mockFile }); + await allSettled(downloadResizedImageClicked, { scope }); + + expect(mockDownloadResizedImage).toHaveBeenCalledTimes(1); + const callArgs = mockDownloadResizedImage.mock.calls[0][0]; + + expect(callArgs.params.mode).toBe('dimensions'); + expect(callArgs.params.width).toBe(800); + expect(callArgs.params.height).toBe(600); + expect(callArgs.originalFile).toBe(mockFile); + }); + + it('должен передавать null для originalFile если файл не загружен', async () => { + const imageUrl = 'blob:http://localhost/test'; + + const scope = fork({ + values: [ + [$image, imageUrl], + [$resizeMode, 'percent'], + [$resizePercent, 100], + [$resizedWidth, 0], + [$resizedHeight, 0], + [$outputFormat, 'original'], + [$quality, 1.0], + ], + }); + + // Не загружаем файл, но устанавливаем image URL напрямую + // Это симулирует ситуацию, когда image есть, но originalFile нет + await allSettled(downloadResizedImageClicked, { scope }); + + expect(mockDownloadResizedImage).toHaveBeenCalledTimes(1); + const callArgs = mockDownloadResizedImage.mock.calls[0][0]; + + // Проверяем, что originalFile null + expect(callArgs.originalFile).toBeNull(); + }); + + it('не должен вызывать эффект если нет изображения', async () => { + const scope = fork({ + values: [ + [$image, null], + [$resizeMode, 'percent'], + [$resizePercent, 100], + [$resizedWidth, 0], + [$resizedHeight, 0], + [$outputFormat, 'original'], + [$quality, 1.0], + ], + }); + + await allSettled(downloadResizedImageClicked, { scope }); + + // Эффект не должен быть вызван из-за filter + expect(mockDownloadResizedImage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/widgets/image-resize-dialog/config.ts b/src/widgets/image-resize-dialog/config.ts new file mode 100644 index 0000000..e8ad9bf --- /dev/null +++ b/src/widgets/image-resize-dialog/config.ts @@ -0,0 +1,78 @@ +/** + * Константы для виджета изменения размера изображения + */ + +// Режимы изменения размера +export const ResizeMode = { + percent: 'percent', + dimensions: 'dimensions', +} as const; + +export type ResizeMode = (typeof ResizeMode)[keyof typeof ResizeMode]; + +// Проценты изменения размера +export const PERCENT = { + MIN: 1, + MAX: 200, + DEFAULT: 100, + DIVISOR: 100, // Делитель для расчета процентов (100%) +} as const; + +// MIME типы изображений +export const MIME_TYPES = { + JPEG: 'image/jpeg', + JPG: 'image/jpg', + PNG: 'image/png', + WEBP: 'image/webp', + GIF: 'image/gif', +} as const; + +// Расширения файлов +export const FILE_EXTENSIONS = { + JPG: 'jpg', + PNG: 'png', + WEBP: 'webp', + GIF: 'gif', +} as const; + +// Маппинг MIME типов на расширения файлов +export const MIME_TO_EXTENSION: Record = { + [MIME_TYPES.JPEG]: FILE_EXTENSIONS.JPG, + [MIME_TYPES.JPG]: FILE_EXTENSIONS.JPG, + [MIME_TYPES.PNG]: FILE_EXTENSIONS.PNG, + [MIME_TYPES.WEBP]: FILE_EXTENSIONS.WEBP, + [MIME_TYPES.GIF]: FILE_EXTENSIONS.GIF, +}; + +// Настройки качества +export const QUALITY = { + MIN: 0.1, + MAX: 1.0, + DEFAULT: 1.0, + STEP: 0.05, +} as const; + +// Настройки для canvas +export const CANVAS = { + JPEG_QUALITY: QUALITY.DEFAULT, + DEFAULT_MIME_TYPE: MIME_TYPES.JPEG, + DEFAULT_EXTENSION: FILE_EXTENSIONS.JPG, +} as const; + +// Префиксы URL +export const URL_PREFIXES = { + BLOB: 'blob:', +} as const; + +// Минимальные размеры +export const MIN_DIMENSION = 1; + +export const DEFAULT_IMAGE_SIZE = { + height: 0, + width: 0, +}; + +export type ImageSize = { + height: number; + width: number; +}; diff --git a/src/widgets/image-resize-dialog/index.ts b/src/widgets/image-resize-dialog/index.ts new file mode 100644 index 0000000..42aad66 --- /dev/null +++ b/src/widgets/image-resize-dialog/index.ts @@ -0,0 +1,2 @@ +export { Dialog, UploadButton } from './view'; +export { OpenButton } from './ui'; diff --git a/src/widgets/image-resize-dialog/lib.ts b/src/widgets/image-resize-dialog/lib.ts new file mode 100644 index 0000000..5f265f4 --- /dev/null +++ b/src/widgets/image-resize-dialog/lib.ts @@ -0,0 +1,354 @@ +import { fileDownloadDirect } from '~/shared/lib/file-download'; +import { loadImageFromUrl } from '~/shared/lib/load-image'; + +import { + CANVAS, + type ImageSize, + MIME_TO_EXTENSION, + MIME_TYPES, + PERCENT, + ResizeMode, + URL_PREFIXES, +} from './config'; + +/** + * Получает размеры изображения по URL + * @param url - URL изображения (может быть blob URL или обычный URL) + * @returns Promise с размерами изображения (ширина и высота) + * @throws {Error} Если не удалось загрузить изображение + */ +export async function getImageSizesFromUrl(url: string): Promise { + const revokeUrl = () => { + // Освобождаем URL только если это blob URL + if (url.startsWith(URL_PREFIXES.BLOB)) { + URL.revokeObjectURL(url); + } + }; + + const img = await loadImageFromUrl(url, revokeUrl); + + return { width: img.naturalWidth, height: img.naturalHeight }; +} + +/** + * Получает размеры изображения из File объекта + * @param file - File объект с изображением + * @returns Promise с размерами изображения (ширина и высота) + * @throws {Error} Если не удалось загрузить изображение + */ +export async function getImageSizesFromBlob(file: File): Promise { + const url = URL.createObjectURL(file); + const revokeUrl = () => { + URL.revokeObjectURL(url); + }; + + const img = await loadImageFromUrl(url, revokeUrl); + + return { width: img.naturalWidth, height: img.naturalHeight }; +} + +/** + * Вычисляет новые размеры изображения с сохранением пропорций на основе процента + * @param originalWidth - оригинальная ширина изображения + * @param originalHeight - оригинальная высота изображения + * @param percent - процент изменения размера (1-200) + * @returns Новые размеры изображения с сохранением пропорций + */ +export function calculateResizedDimensions( + originalWidth: number, + originalHeight: number, + percent: number, +): ImageSize { + const { DIVISOR } = PERCENT; + return { + width: Math.round((originalWidth * percent) / DIVISOR), + height: Math.round((originalHeight * percent) / DIVISOR), + }; +} + +/** + * Вычисляет пропорциональную сторону на основе измененной стороны + * @param originalWidth - оригинальная ширина + * @param originalHeight - оригинальная высота + * @param changedValue - новое значение измененной стороны + * @param changedSide - какая сторона была изменена ('width' или 'height') + * @returns новое значение второй стороны + */ +export function calculateProportionalDimension( + originalWidth: number, + originalHeight: number, + changedValue: number, + changedSide: 'width' | 'height', +): number { + if (originalWidth === 0 || originalHeight === 0) { + return changedValue; + } + + const aspectRatio = originalWidth / originalHeight; + + if (changedSide === 'width') { + // Изменили ширину, вычисляем высоту + return Math.round(changedValue / aspectRatio); + } else { + // Изменили высоту, вычисляем ширину + return Math.round(changedValue * aspectRatio); + } +} + +/** + * Определяет качество для формата изображения + * @param mimeType - MIME тип изображения + * @param requestedQuality - Запрошенное качество (опционально) + * @returns Качество для формата или undefined для PNG + */ +function getQualityForFormat( + mimeType: string, + requestedQuality?: number, +): number | undefined { + // PNG не поддерживает качество (lossless) + if (mimeType === MIME_TYPES.PNG) { + return undefined; + } + + // Для форматов, поддерживающих качество, используем запрошенное или значение по умолчанию + return requestedQuality ?? CANVAS.JPEG_QUALITY; +} + +/** + * Изменяет размер изображения на указанный процент через canvas + * @param image - HTMLImageElement с загруженным изображением + * @param percent - процент изменения размера (1-200) + * @param outputFormat - MIME тип формата вывода (опционально) + * @param quality - Качество сжатия (0.1-1.0), используется только для JPEG и WebP (опционально) + * @returns Promise с Blob измененного изображения + * @throws {Error} Если не удалось получить canvas context или создать blob + */ +async function resizeImageByPercent( + image: HTMLImageElement, + percent: number, + outputFormat?: string, + quality?: number, +): Promise { + const { width, height } = calculateResizedDimensions( + image.naturalWidth, + image.naturalHeight, + percent, + ); + + return resizeImageByDimensions(image, width, height, outputFormat, quality); +} + +/** + * Создает Blob из canvas с указанным форматом и качеством + * @param canvas - Canvas элемент + * @param mimeType - MIME тип формата вывода + * @param quality - Качество сжатия (опционально) + * @returns Promise с Blob + * @throws {Error} Если не удалось создать blob + */ +function canvasToBlob( + canvas: HTMLCanvasElement, + mimeType: string, + quality?: number, +): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + blob => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to create blob from canvas')); + } + }, + mimeType, + quality, + ); + }); +} + +/** + * Изменяет размер изображения на указанные размеры через canvas + * @param image - HTMLImageElement с загруженным изображением + * @param width - новая ширина в пикселях + * @param height - новая высота в пикселях + * @param outputFormat - MIME тип формата вывода (опционально) + * @param quality - Качество сжатия (0.1-1.0), используется только для JPEG и WebP (опционально) + * @returns Promise с Blob измененного изображения + * @throws {Error} Если не удалось получить canvas context или создать blob + */ +async function resizeImageByDimensions( + image: HTMLImageElement, + width: number, + height: number, + outputFormat?: string, + quality?: number, +): Promise { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.drawImage(image, 0, 0, width, height); + + // Определяем MIME тип и качество + const mimeType = outputFormat || CANVAS.DEFAULT_MIME_TYPE; + const finalQuality = getQualityForFormat(mimeType, quality); + + return canvasToBlob(canvas, mimeType, finalQuality); +} + +/** + * Параметры для изменения размера изображения + * @property mode - Режим изменения размера: 'percent' для процентов или 'dimensions' для размеров + * @property imageUrl - URL изображения для изменения размера + * @property percent - Процент изменения размера (1-200), используется только при mode === 'percent' + * @property width - Ширина в пикселях, используется только при mode === 'dimensions' + * @property height - Высота в пикселях, используется только при mode === 'dimensions' + * @property outputFormat - MIME тип формата вывода (опционально) + * @property quality - Качество сжатия (0.1-1.0), используется только для JPEG и WebP (опционально) + */ +export type ResizeImageParams = + | { + mode: 'percent'; + imageUrl: string; + percent: number; + outputFormat?: string; + quality?: number; + } + | { + mode: 'dimensions'; + imageUrl: string; + width: number; + height: number; + outputFormat?: string; + quality?: number; + }; + +/** + * Подготавливает параметры для изменения размера изображения + * @param imageUrl - URL изображения для изменения размера + * @param mode - Режим изменения размера (ResizeMode.percent или ResizeMode.dimensions) + * @param percent - Процент изменения размера (1-200), используется только при mode === 'percent' + * @param width - Ширина в пикселях, используется только при mode === 'dimensions' + * @param height - Высота в пикселях, используется только при mode === 'dimensions' + * @param outputFormat - MIME тип формата вывода (опционально) + * @param quality - Качество сжатия (0.1-1.0), используется только для JPEG и WebP (опционально) + * @returns Параметры для изменения размера изображения + */ +export function prepareResizeParams( + imageUrl: string, + mode: ResizeMode, + percent: number, + width: number, + height: number, + outputFormat?: string, + quality?: number, +): ResizeImageParams { + if (mode === ResizeMode.percent) { + return { mode, imageUrl, percent, outputFormat, quality }; + } else { + return { mode, imageUrl, width, height, outputFormat, quality }; + } +} + +/** + * Загружает изображение по URL и изменяет его размер + * @param params - Параметры изменения размера (процент или размеры) + * @returns Promise с Blob измененного изображения + * @throws {Error} Если не удалось загрузить изображение, получить canvas context или создать blob + */ +export async function resizeImageFromUrl( + params: ResizeImageParams, +): Promise { + const image = await loadImageFromUrl(params.imageUrl); + + if (params.mode === ResizeMode.dimensions) { + // Режим размеров + return resizeImageByDimensions( + image, + params.width, + params.height, + params.outputFormat, + params.quality, + ); + } + + // Режим процентов + return resizeImageByPercent( + image, + params.percent, + params.outputFormat, + params.quality, + ); +} + +/** + * Получает расширение файла из MIME типа + * @param mime - MIME тип изображения (например, 'image/jpeg', 'image/png') + * @returns Расширение файла без точки (например, 'jpg', 'png') + * @default 'jpg' - если MIME тип не распознан + */ +export function getExtensionFromMime(mime: string): string { + return MIME_TO_EXTENSION[mime] || CANVAS.DEFAULT_EXTENSION; +} + +/** + * Параметры для скачивания измененного изображения + */ +export type DownloadResizedImageParams = { + params: ResizeImageParams; + originalFile: File | null; +}; + +/** + * Загружает изображение, изменяет его размер и скачивает + * @param downloadParams - Параметры для скачивания (параметры изменения размера и оригинальный файл) + * @returns Promise + */ +export async function downloadResizedImage({ + params, + originalFile, +}: DownloadResizedImageParams): Promise { + // Загружаем изображение и изменяем размер + const blob = await resizeImageFromUrl(params); + + // Определяем MIME тип для расширения файла + // Приоритет: params.outputFormat > blob.type > originalFile.type > DEFAULT + // Canvas должен правильно устанавливать blob.type, но на всякий случай есть fallback + const mime = + params.outputFormat || + blob.type || + originalFile?.type || + CANVAS.DEFAULT_MIME_TYPE; + const extension = getExtensionFromMime(mime); + + // Формируем имя файла + let defaultName: string; + if (originalFile) { + // Извлекаем имя файла без расширения из оригинала + const match = originalFile.name.match(/^(.+?)(\.[^.]+)?$/); + if (match) { + const nameWithoutExtension = match[1]; + defaultName = `${nameWithoutExtension}_resized.${extension}`; + } else { + // Если нет расширения в оригинале + defaultName = `${originalFile.name}_resized.${extension}`; + } + } else { + defaultName = `resized-image.${extension}`; + } + + fileDownloadDirect(blob, defaultName); +} + +// Экспорт алиасов для тестирования внутренних функций +export { + canvasToBlob as _canvasToBlob, + getQualityForFormat as _getQualityForFormat, + resizeImageByPercent as _resizeImageByPercent, + resizeImageByDimensions as _resizeImageByDimensions, +}; diff --git a/src/widgets/image-resize-dialog/model.ts b/src/widgets/image-resize-dialog/model.ts new file mode 100644 index 0000000..cf91e0f --- /dev/null +++ b/src/widgets/image-resize-dialog/model.ts @@ -0,0 +1,396 @@ +import { invoke } from '@withease/factories'; +import { + attach, + combine, + createEffect, + createEvent, + createStore, + restore, + sample, +} from 'effector'; + +import { imageUploadBaseFx } from '~/entities/image'; + +import { createDialogInstance } from '~/shared/lib/dialog-Instance'; + +import { + CANVAS, + DEFAULT_IMAGE_SIZE, + type ImageSize, + MIME_TYPES, + PERCENT, + QUALITY, + ResizeMode, +} from './config'; +import { + calculateProportionalDimension, + calculateResizedDimensions, + downloadResizedImage, + getImageSizesFromBlob, + prepareResizeParams, +} from './lib'; + +// Эффект для скачивания измененного изображения +const downloadResizedImageFx = createEffect(downloadResizedImage); + +const imageSizeFx = createEffect(getImageSizesFromBlob); +const imageUploadFx = attach({ effect: imageUploadBaseFx }); + +export const imageUploadStarted = createEvent(); + +// Режим пересчета +export const resizeModeChanged = createEvent(); +export const $resizeMode = restore(resizeModeChanged, ResizeMode.percent); + +// Режим процентов +export const resizePercentChanged = createEvent(); +export const resizePercentReset = createEvent(); + +export const resetButtonClicked = createEvent(); + +// Режим размеров +export const resizedWidthChanged = createEvent(); +export const resizedHeightChanged = createEvent(); +const resizeDimensionsReset = createEvent(); + +export const downloadResizedImageClicked = createEvent(); + +// Формат вывода +export const outputFormatChanged = createEvent(); + +// Качество сжатия +export const qualityChanged = createEvent(); +export const qualityReset = createEvent(); + +const formReinit = createEvent(); + +const $imageRaw = createStore(null).on( + imageUploadFx.done, + (_, response) => response.params, +); + +export const $imageSize = createStore(DEFAULT_IMAGE_SIZE).on( + imageSizeFx.doneData, + (_, res) => res, +); + +export const $image = restore(imageUploadFx.doneData, null); + +export const $resizePercent = restore( + resizePercentChanged, + PERCENT.DEFAULT, +).reset([resizePercentReset]); + +// Режим размеров: stores для ширины и высоты +export const $resizedWidth = createStore(0); +export const $resizedHeight = createStore(0); + +export const $resizedImageSize = createStore(DEFAULT_IMAGE_SIZE); + +export const $outputFormat = restore( + outputFormatChanged, + CANVAS.DEFAULT_MIME_TYPE, +) + .on(imageUploadFx.done, (_, { params }) => params.type) + .reset(formReinit); + +// Сохраняем исходный формат файла для сравнения +const $originalFileFormat = createStore(CANVAS.DEFAULT_MIME_TYPE).reset( + [formReinit], +); + +export const $quality = restore(qualityChanged, QUALITY.DEFAULT).reset([ + qualityReset, + formReinit, +]); + +export const dialogInstance = invoke(() => + createDialogInstance({ onAfterClosed: formReinit }), +); + +export const $downloadPending = downloadResizedImageFx.pending; + +export const $disabledDownload = combine( + $image, + $resizeMode, + $resizePercent, + $imageSize, + $resizedWidth, + $resizedHeight, + $outputFormat, + $originalFileFormat, + $quality, + ( + image, + mode, + resizePercent, + imageSize, + resizedWidth, + resizedHeight, + outputFormat, + originalFileFormat, + quality, + ) => { + if (!image) { + return true; + } + + // Если формат изменен от исходного, кнопка должна быть активна + if (outputFormat !== originalFileFormat) { + return false; + } + + // Если качество изменено, кнопка должна быть активна + if (quality !== QUALITY.DEFAULT) { + return false; + } + + if (mode === ResizeMode.percent) { + return resizePercent === PERCENT.DEFAULT; + } + + return ( + resizedWidth === imageSize.width && resizedHeight === imageSize.height + ); + }, +); + +// Инициализация размеров при загрузке изображения +sample({ + clock: $imageSize, + filter: size => size.width > 0 && size.height > 0, + fn: size => size.width, + target: $resizedWidth, +}); + +sample({ + clock: $imageSize, + filter: size => size.width > 0 && size.height > 0, + fn: size => size.height, + target: $resizedHeight, +}); + +// Сброс размеров на оригинальные +sample({ + clock: resizeDimensionsReset, + source: $imageSize, + fn: size => size.width, + target: $resizedWidth, +}); + +sample({ + clock: resizeDimensionsReset, + source: $imageSize, + fn: size => size.height, + target: $resizedHeight, +}); + +// Обновляем размер при изменении исходного размера или процента (режим процентов) +sample({ + clock: [$imageSize, $resizePercent, $resizeMode], + source: { + imageSize: $imageSize, + percent: $resizePercent, + mode: $resizeMode, + }, + filter: ({ mode }) => mode === ResizeMode.percent, + fn: ({ imageSize, percent }) => { + if (imageSize.width === 0 || imageSize.height === 0) { + return DEFAULT_IMAGE_SIZE; + } + + return calculateResizedDimensions( + imageSize.width, + imageSize.height, + percent, + ); + }, + target: $resizedImageSize, +}); + +// При переключении на режим размеров, сбрасываем размеры до оригинальных +sample({ + clock: $resizeMode, + source: $imageSize, + fn: imageSize => imageSize.width, + target: $resizedWidth, +}); + +sample({ + clock: $resizeMode, + source: $imageSize, + fn: imageSize => imageSize.height, + target: $resizedHeight, +}); + +// При переключении на режим процентов, сбрасываем процент до значения по умолчанию +sample({ + clock: $resizeMode, + fn: () => PERCENT.DEFAULT, + target: $resizePercent, +}); + +// Обновляем размер при изменении ширины/высоты (режим размеров) +sample({ + clock: [$resizedWidth, $resizedHeight], + source: { width: $resizedWidth, height: $resizedHeight }, + fn: ({ width, height }) => ({ width, height }), + target: $resizedImageSize, +}); + +// Логика пересчета пропорций при изменении ширины (режим размеров) +// Всегда обновляем ширину +sample({ + clock: resizedWidthChanged, + source: { mode: $resizeMode }, + filter: ({ mode }) => mode === ResizeMode.dimensions, + fn: (_, newWidth) => newWidth, + target: $resizedWidth, +}); + +// Всегда пересчитываем высоту пропорционально +sample({ + clock: resizedWidthChanged, + source: { mode: $resizeMode, imageSize: $imageSize }, + filter: ({ mode, imageSize }) => + mode === ResizeMode.dimensions && + imageSize.width > 0 && + imageSize.height > 0, + fn: ({ imageSize }, newWidth) => + calculateProportionalDimension( + imageSize.width, + imageSize.height, + newWidth, + 'width', + ), + target: $resizedHeight, +}); + +// Логика пересчета пропорций при изменении высоты (режим размеров) +// Всегда обновляем высоту +sample({ + clock: resizedHeightChanged, + source: { mode: $resizeMode }, + filter: ({ mode }) => mode === ResizeMode.dimensions, + fn: (_, newHeight) => newHeight, + target: $resizedHeight, +}); + +// Всегда пересчитываем ширину пропорционально +sample({ + clock: resizedHeightChanged, + source: { + mode: $resizeMode, + imageSize: $imageSize, + }, + filter: ({ mode, imageSize }) => + mode === ResizeMode.dimensions && + imageSize.width > 0 && + imageSize.height > 0, + fn: ({ imageSize }, newHeight) => + calculateProportionalDimension( + imageSize.width, + imageSize.height, + newHeight, + 'height', + ), + target: $resizedWidth, +}); + +sample({ + clock: imageUploadStarted, + target: imageUploadFx, +}); + +// Устанавливаем формат вывода из типа загруженного файла +sample({ + clock: imageUploadStarted, + filter: (file: File) => { + // Проверяем, что тип файла поддерживается + const supportedTypes = [ + MIME_TYPES.JPEG, + MIME_TYPES.JPG, + MIME_TYPES.PNG, + MIME_TYPES.WEBP, + MIME_TYPES.GIF, + ]; + return supportedTypes.includes( + file.type as (typeof MIME_TYPES)[keyof typeof MIME_TYPES], + ); + }, + fn: (file: File) => file.type || CANVAS.DEFAULT_MIME_TYPE, + target: [outputFormatChanged, $originalFileFormat], +}); + +sample({ clock: $imageRaw, filter: Boolean, target: imageSizeFx }); + +// Скачиваем изображение при клике - загружаем, изменяем размер и скачиваем +sample({ + clock: downloadResizedImageClicked, + source: { + image: $image, + mode: $resizeMode, + percent: $resizePercent, + width: $resizedWidth, + height: $resizedHeight, + originalFile: $imageRaw, + outputFormat: $outputFormat, + quality: $quality, + }, + filter: ({ image }) => Boolean(image), + fn: ({ + image, + mode, + percent, + width, + height, + originalFile, + outputFormat, + quality, + }) => ({ + params: prepareResizeParams( + image!, + mode, + percent, + width, + height, + outputFormat, + quality, + ), + originalFile, + }), + target: downloadResizedImageFx, +}); + +sample({ + clock: resetButtonClicked, + target: [resizePercentReset, resizeDimensionsReset, qualityReset], +}); + +sample({ + clock: [imageUploadFx.fail, imageUploadStarted, dialogInstance.afterClosed], + target: formReinit, +}); + +sample({ + clock: formReinit, + target: [ + $imageRaw.reinit, + $image.reinit, + $imageSize.reinit, + $resizePercent.reinit, + $resizedWidth.reinit, + $resizedHeight.reinit, + $resizedImageSize.reinit, + $outputFormat.reinit, + $quality.reinit, + ], +}); + +// testing exports +export { + downloadResizedImageFx as _downloadResizedImageFx, + imageUploadFx as _imageUploadFx, + imageSizeFx as _imageSizeFx, + $imageRaw as _$imageRaw, +}; diff --git a/src/widgets/image-resize-dialog/ui/button.tsx b/src/widgets/image-resize-dialog/ui/button.tsx new file mode 100644 index 0000000..c10397c --- /dev/null +++ b/src/widgets/image-resize-dialog/ui/button.tsx @@ -0,0 +1,64 @@ +import { useUnit } from 'effector-react'; +import { Download, RotateCcw } from 'lucide-react'; + +import { Button } from '~/shared/ui'; + +import { + $disabledDownload, + $downloadPending, + dialogInstance, + downloadResizedImageClicked, + resetButtonClicked, +} from '../model'; + +export function OpenButton() { + const onClick = useUnit(dialogInstance.open); + return ; +} + +export function ResetButton() { + const [disabled, handleClick] = useUnit([ + $disabledDownload, + resetButtonClicked, + ]); + + return ( + + ); +} + +export function DownloadButton() { + const [disabledDownload, pending, onClick] = useUnit([ + $disabledDownload, + $downloadPending, + downloadResizedImageClicked, + ]); + + return ( + + ); +} diff --git a/src/widgets/image-resize-dialog/ui/dialog.tsx b/src/widgets/image-resize-dialog/ui/dialog.tsx new file mode 100644 index 0000000..d45f6aa --- /dev/null +++ b/src/widgets/image-resize-dialog/ui/dialog.tsx @@ -0,0 +1,31 @@ +import { useUnit } from 'effector-react'; + +import { + Dialog as DialogBase, + DialogCloseButton, + DialogHeader, +} from '~/shared/ui'; + +import { dialogInstance } from '../model'; + +interface DialogBaseProps { + children?: React.ReactNode; + title?: string; +} + +export function Dialog({ children, title }: DialogBaseProps) { + const [open, close] = useUnit([ + dialogInstance.$isOpened, + dialogInstance.close, + ]); + + return ( + +
+ + +
+ {children} +
+ ); +} diff --git a/src/widgets/image-resize-dialog/ui/form.tsx b/src/widgets/image-resize-dialog/ui/form.tsx new file mode 100644 index 0000000..a777f39 --- /dev/null +++ b/src/widgets/image-resize-dialog/ui/form.tsx @@ -0,0 +1,277 @@ +import { useId } from 'react'; + +import { useUnit } from 'effector-react'; + +import { InputNumber, Select, Slider } from '~/shared/ui'; + +import { + MIME_TYPES, + MIN_DIMENSION, + PERCENT, + QUALITY, + ResizeMode, +} from '../config'; +import { + $downloadPending, + $image, + $imageSize, + $outputFormat, + $quality, + $resizeMode, + $resizePercent, + $resizedHeight, + $resizedImageSize, + $resizedWidth, + outputFormatChanged, + qualityChanged, + qualityReset, + resizeModeChanged, + resizePercentChanged, + resizePercentReset, + resizedHeightChanged, + resizedWidthChanged, +} from '../model'; +import { DownloadButton, ResetButton } from './button'; + +export function ResizeModeSelect() { + const [value, handleChange] = useUnit([$resizeMode, resizeModeChanged]); + + return ( + + ); +} + +export function PercentInputField() { + const id = useId(); + + const [value, handleChange, handleReset] = useUnit([ + $resizePercent, + resizePercentChanged, + resizePercentReset, + ]); + + return ( +
+ + + { + const value = Number(event.target.value); + if (value >= PERCENT.MIN && value <= PERCENT.MAX) { + handleChange(value); + } + }} + /> +
+ ); +} + +function WidthInputField() { + const [width, handleChange] = useUnit([$resizedWidth, resizedWidthChanged]); + + const id = useId(); + + return ( +
+ + +
+ { + const value = Number(event.target.value); + if (value >= MIN_DIMENSION || event.target.value === '') { + handleChange(value || 0); + } + }} + /> + px +
+
+ ); +} + +function HeightInputField() { + const id = useId(); + const [value, handleChange] = useUnit([$resizedHeight, resizedHeightChanged]); + + return ( +
+ + +
+ { + const value = Number(event.target.value); + if (value >= MIN_DIMENSION || event.target.value === '') { + handleChange(value || 0); + } + }} + /> + px +
+
+ ); +} + +export function DimensionsInputFields() { + return ( +
+ + + +
+ ); +} + +export function ImageInfoSection() { + return ( +
+ + +
+ ); +} + +interface ImageInfoProps { + title: string; + children: React.ReactNode; +} + +function ImageInfo({ children, title }: ImageInfoProps) { + return ( +
+ {title}: + {children} +
+ ); +} + +function OriginalImageInfo() { + const imageSize = useUnit($imageSize); + + return ( + + {imageSize.width} × {imageSize.height} px + + ); +} + +function NewImageInfo() { + const imageSize = useUnit($resizedImageSize); + + return ( + + {imageSize.width} × {imageSize.height} px + + ); +} + +export function FormatSelect() { + const [value, handleChange] = useUnit([$outputFormat, outputFormatChanged]); + + return ( + + ); +} + +export function QualitySlider() { + const [quality, setQuality, resetQuality, outputFormat] = useUnit([ + $quality, + qualityChanged, + qualityReset, + $outputFormat, + ]); + + // Конвертируем в проценты для отображения + const qualityPercent = Math.round(quality * PERCENT.DIVISOR); + const minPercent = Math.round(QUALITY.MIN * PERCENT.DIVISOR); + const maxPercent = Math.round(QUALITY.MAX * PERCENT.DIVISOR); + const stepPercent = Math.round(QUALITY.STEP * PERCENT.DIVISOR); + + return ( + setQuality(value / PERCENT.DIVISOR)} + className="disabled:cursor-not-allowed disabled:opacity-50" + /> + ); +} + +interface FieldsetProps { + className?: string; + children: React.ReactNode; +} +export function Fieldset(props: FieldsetProps) { + const { children, className } = props; + const [pending, image] = useUnit([$downloadPending, $image]); + + return ( +
+ {children} +
+ ); +} + +export function FormActions() { + return ( +
+ + + +
+ ); +} diff --git a/src/widgets/image-resize-dialog/ui/index.ts b/src/widgets/image-resize-dialog/ui/index.ts new file mode 100644 index 0000000..c9e33c7 --- /dev/null +++ b/src/widgets/image-resize-dialog/ui/index.ts @@ -0,0 +1,12 @@ +export { OpenButton } from './button'; +export { Dialog } from './dialog'; +export { + DimensionsInputFields, + FormatSelect, + ImageInfoSection, + PercentInputField, + QualitySlider, + ResizeModeSelect, + FormActions, + Fieldset, +} from './form'; diff --git a/src/widgets/image-resize-dialog/view.tsx b/src/widgets/image-resize-dialog/view.tsx new file mode 100644 index 0000000..9045e93 --- /dev/null +++ b/src/widgets/image-resize-dialog/view.tsx @@ -0,0 +1,89 @@ +import { useRef } from 'react'; + +import { useUnit } from 'effector-react'; +import { ImageOff } from 'lucide-react'; + +import { Button, FileInput } from '~/shared/ui'; + +import { $image, $resizeMode, imageUploadStarted } from './model'; +import { + Dialog as DialogC, + DimensionsInputFields, + Fieldset, + FormActions, + FormatSelect, + ImageInfoSection, + PercentInputField, + QualitySlider, + ResizeModeSelect, +} from './ui'; + +export function Dialog() { + const [image, resizeMode] = useUnit([$image, $resizeMode]); + + return ( + +
+ +
+
+ + +
+ + {resizeMode === 'percent' ? ( + + ) : ( + + )} + + +
+ + +
+ + +
+
+
+ ); +} + +function Preview({ image }: { image?: string | null }) { + return ( +
+

Превью

+
+ {image ? ( + Original + ) : ( + + )} +
+
+ ); +} + +export function UploadButton() { + const ref = useRef(null); + const onUpload = useUnit(imageUploadStarted); + + return ( + <> + + + + + ); +}