Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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