Skip to content
Merged

Dev #22

15 changes: 11 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "piclet-web-editor",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -21,6 +21,7 @@
]
},
"dependencies": {
"@withease/factories": "^1.0.5",
"clsx": "^2.1.1",
"effector": "^23.4.4",
"effector-react": "^23.3.0",
Expand Down
4 changes: 4 additions & 0 deletions src/entities/image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
mountCanvas,
resetFilters,
unmountCanvas,
imageUploadBaseFx,
} from './model';

// Stores
Expand All @@ -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';
31 changes: 31 additions & 0 deletions src/entities/image/lib.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement> {
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;
});
}

/**
* Опции для настройки отображения сетки
*/
Expand Down
6 changes: 5 additions & 1 deletion src/entities/image/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
attach,
createEffect,
createEvent,
createStore,
Expand Down Expand Up @@ -137,6 +138,8 @@ export class CanvasEditor {
/**
* Загружает изображение по URL.
* @param url - Ссылка на изображение
* @todo Заменить на общую утилиту loadImageFromUrl из ~/entities/image/lib
* после добавления поддержки crossOrigin в loadImageFromUrl
*/
public async loadImage(url: string): Promise<boolean> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -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<{
Expand Down
90 changes: 39 additions & 51 deletions src/features/download-image/__tests__/model.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<typeof vi.spyOn>;
let createObjectURLSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -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 () => {
Expand All @@ -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');
Expand All @@ -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({
Expand All @@ -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]],
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down
31 changes: 31 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,4 +53,9 @@
button {
cursor: pointer;
}

img {
user-select: none;
pointer-events: none;
}
}
Loading