feat: update upload serive, start update image manager
This commit is contained in:
@@ -94,7 +94,7 @@
|
|||||||
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
|
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
|
||||||
|
|
||||||
4. **Сохранить обрезанное изображение**:
|
4. **Сохранить обрезанное изображение**:
|
||||||
- Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными
|
- Метод `imageState.applyCrop()` должен обновлять `image` с обрезанными данными
|
||||||
- Использовать canvas для actual cropping
|
- Использовать canvas для actual cropping
|
||||||
|
|
||||||
### 2.2 Валидация и состояния загрузки (Medium Priority)
|
### 2.2 Валидация и состояния загрузки (Medium Priority)
|
||||||
|
|||||||
@@ -52,18 +52,18 @@ export const textsState = createState();
|
|||||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||||
|
|
||||||
// ПЛОХО - класс с $state полями, но без фабрики
|
// ПЛОХО - класс с $state полями, но без фабрики
|
||||||
export class ImageConfigState {
|
export class imageState {
|
||||||
image = $state<HTMLImageElement | undefined>(undefined);
|
image = $state<HTMLImageElement | undefined>(undefined);
|
||||||
imageLink = $state("");
|
imageLink = $state("");
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
export const imageConfigState = new ImageConfigState();
|
export const imageState = new imageState();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Проблемы:**
|
**Проблемы:**
|
||||||
|
|
||||||
- `konvaAllStagesState` - глобальный изменяемый массив, нет контроля над операциями
|
- `konvaAllStagesState` - глобальный изменяемый массив, нет контроля над операциями
|
||||||
- `ImageConfigState` - класс с $state, но не фабрика, сложно тестировать
|
- `imageState` - класс с $state, но не фабрика, сложно тестировать
|
||||||
- Нет единого подхода
|
- Нет единого подхода
|
||||||
|
|
||||||
#### Паттерн C: Смешанный (⚠️ НЕПРАВИЛЬНО)
|
#### Паттерн C: Смешанный (⚠️ НЕПРАВИЛЬНО)
|
||||||
@@ -151,14 +151,14 @@ export const imageConfigState = new ImageConfigState();
|
|||||||
**Оценка:** ⚠️ **Частично реализовано (нефункционально)**
|
**Оценка:** ⚠️ **Частично реализовано (нефункционально)**
|
||||||
|
|
||||||
- ImageManager.svelte — UI есть, но кнопки не работают (Upload, Edit, Reset)
|
- ImageManager.svelte — UI есть, но кнопки не работают (Upload, Edit, Reset)
|
||||||
- CropInline.svelte — визуальный crop box с handles, но нет интеграции с cropperjs и imageConfigState
|
- CropInline.svelte — визуальный crop box с handles, но нет интеграции с cropperjs и imageState
|
||||||
- Настройки яркости/контраста — только UI, нет логики применения
|
- Настройки яркости/контраста — только UI, нет логики применения
|
||||||
- **Вывод:** Компоненты выглядят готовыми, но функциональность отсутствует
|
- **Вывод:** Компоненты выглядят готовыми, но функциональность отсутствует
|
||||||
|
|
||||||
**Рекомендации (High Priority):**
|
**Рекомендации (High Priority):**
|
||||||
|
|
||||||
- Реализовать imageService.ts и интегрировать в ImageManager
|
- Реализовать imageService.ts и интегрировать в ImageManager
|
||||||
- Добавить логику crop в CropInline (drag, resize, update imageConfigState)
|
- Добавить логику crop в CropInline (drag, resize, update imageState)
|
||||||
- Применить фильтры яркости/контраста к Konva Image в Preview.svelte
|
- Применить фильтры яркости/контраста к Konva Image в Preview.svelte
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -356,7 +356,7 @@ interface ImageService {
|
|||||||
**Архитектура:**
|
**Архитектура:**
|
||||||
|
|
||||||
```
|
```
|
||||||
textsState (массив текстов) + textConfigState (глобальные настройки) + imageConfigState (глобальное изображение)
|
textsState (массив текстов) + textConfigState (глобальные настройки) + imageState (глобальное изображение)
|
||||||
↓
|
↓
|
||||||
PreviewManager (навигация по textsState)
|
PreviewManager (навигация по textsState)
|
||||||
↓
|
↓
|
||||||
@@ -378,7 +378,7 @@ downloadService (экспорт)
|
|||||||
|
|
||||||
- `TextManager` → `textsState.addText()` / `removeText()`
|
- `TextManager` → `textsState.addText()` / `removeText()`
|
||||||
- `PreviewManager` → читает `textsState.texts` для навигации
|
- `PreviewManager` → читает `textsState.texts` для навигации
|
||||||
- `Preview` → читает `textsState.texts[current]` + `textConfigState` + `imageConfigState`
|
- `Preview` → читает `textsState.texts[current]` + `textConfigState` + `imageState`
|
||||||
|
|
||||||
Это **достаточно** для MVP.
|
Это **достаточно** для MVP.
|
||||||
|
|
||||||
@@ -559,7 +559,7 @@ it("should create panel with text and export it", async () => {
|
|||||||
**2. Изображение flow:**
|
**2. Изображение flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
ImageManager → imageService → imageConfigState → Preview → export
|
ImageManager → imageService → imageState → Preview → export
|
||||||
```
|
```
|
||||||
|
|
||||||
**Тест:** [`tests/integration/image-flow.test.ts`](tests/integration/image-flow.test.ts)
|
**Тест:** [`tests/integration/image-flow.test.ts`](tests/integration/image-flow.test.ts)
|
||||||
@@ -567,7 +567,7 @@ ImageManager → imageService → imageConfigState → Preview → export
|
|||||||
```typescript
|
```typescript
|
||||||
it("should upload image, crop it, and apply to panel", async () => {
|
it("should upload image, crop it, and apply to panel", async () => {
|
||||||
// 1. Загрузить изображение (mock file)
|
// 1. Загрузить изображение (mock file)
|
||||||
// 2. Проверить imageConfigState.image
|
// 2. Проверить imageState.image
|
||||||
// 3. Установить crop values
|
// 3. Установить crop values
|
||||||
// 4. Проверить Preview отображает обрезанное изображение
|
// 4. Проверить Preview отображает обрезанное изображение
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { imageState } from "$states/image.svelte";
|
||||||
|
|
||||||
let active = $state(false);
|
let active = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="crop-canvas-container">
|
<div class="crop-canvas-container">
|
||||||
|
<img src={imageState.fullImage} alt="Background for use" />
|
||||||
<canvas class="crop-canvas"></canvas>
|
<canvas class="crop-canvas"></canvas>
|
||||||
<div class="crop-box" class:active>
|
<div class="crop-box" class:active>
|
||||||
<div class="crop-handle nw"></div>
|
<div class="crop-handle nw"></div>
|
||||||
|
|||||||
@@ -4,17 +4,25 @@
|
|||||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
import { IMAGE_SETTINGS } from "$lib/constants";
|
||||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
import { uploadService } from "$services/uploadService";
|
||||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
import { imageState } from "$states/image.svelte";
|
||||||
// import Upload from "@lucide/svelte/icons/upload";
|
|
||||||
import Pencil from "~icons/lucide/pencil";
|
import Pencil from "~icons/lucide/pencil";
|
||||||
import Reset from "~icons/lucide/rotate-ccw";
|
import Reset from "~icons/lucide/rotate-ccw";
|
||||||
import Upload from "~icons/lucide/upload";
|
import Upload from "~icons/lucide/upload";
|
||||||
import CropInline from "./CropInline.svelte";
|
import CropInline from "./CropInline.svelte";
|
||||||
|
|
||||||
let brightness = $state(100);
|
let brightness = $state(IMAGE_SETTINGS.DEFAULT_BRIGHTNESS);
|
||||||
let contrast = $state(100);
|
let contrast = $state(IMAGE_SETTINGS.DEFAULT_CONTRAST);
|
||||||
|
let hue = $state(IMAGE_SETTINGS.DEFAULT_HUE);
|
||||||
|
let chroma = $state(IMAGE_SETTINGS.DEFAULT_CHROMA);
|
||||||
|
|
||||||
|
async function handlePaste(event: ClipboardEvent) {
|
||||||
|
let image = await uploadService.fromClipboard(event);
|
||||||
|
if (image.ok) {
|
||||||
|
imageState.fullImage = image.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Фоновое изображение">
|
<Card title="Фоновое изображение">
|
||||||
@@ -22,22 +30,42 @@
|
|||||||
<CropInline />
|
<CropInline />
|
||||||
|
|
||||||
<div class="crop-controls">
|
<div class="crop-controls">
|
||||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="primary" />
|
||||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
<Button label="Редактировать" ariaLabel="Edit" icon={Pencil} type="secondary" />
|
||||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
<Button label="Сбросить" ariaLabel="Reset" icon={Reset} type="outline" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsGrid>
|
<SettingsGrid>
|
||||||
|
<SettingsRow label="Смещение цвета">
|
||||||
|
<RangeSlider bind:value={hue} min={IMAGE_SETTINGS.HUE_MIN} max={IMAGE_SETTINGS.HUE_MAX} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow label="Насыщенность">
|
||||||
|
<RangeSlider
|
||||||
|
bind:value={chroma}
|
||||||
|
min={IMAGE_SETTINGS.CHROMA_MIN}
|
||||||
|
max={IMAGE_SETTINGS.CHROMA_MAX}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow label="Яркость">
|
<SettingsRow label="Яркость">
|
||||||
<RangeSlider bind:value={brightness} />
|
<RangeSlider
|
||||||
|
bind:value={brightness}
|
||||||
|
min={IMAGE_SETTINGS.BRIGHTNESS_MIN}
|
||||||
|
max={IMAGE_SETTINGS.BRIGHTNESS_MAX}
|
||||||
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Контраст">
|
<SettingsRow label="Контраст">
|
||||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
<RangeSlider
|
||||||
|
bind:value={contrast}
|
||||||
|
min={IMAGE_SETTINGS.CONTRAST_MIN}
|
||||||
|
max={IMAGE_SETTINGS.CONTRAST_MAX}
|
||||||
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGrid>
|
</SettingsGrid>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<svelte:window onpaste={handlePaste} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.crop-editor {
|
.crop-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+19
-2
@@ -5,8 +5,6 @@ export const PANEL_SETTINGS = {
|
|||||||
PANEL_WIDTH: 320,
|
PANEL_WIDTH: 320,
|
||||||
PANEL_HEIGHT_DEFAULT: 100,
|
PANEL_HEIGHT_DEFAULT: 100,
|
||||||
PANEL_HEIGHT_MAX: 200,
|
PANEL_HEIGHT_MAX: 200,
|
||||||
|
|
||||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ===== TYPOGRAPHY =====
|
// ===== TYPOGRAPHY =====
|
||||||
@@ -33,6 +31,7 @@ export const TYPOGRAPHY = {
|
|||||||
|
|
||||||
// Padding for range inputs
|
// Padding for range inputs
|
||||||
PADDING_X_DEFAULT: 10,
|
PADDING_X_DEFAULT: 10,
|
||||||
|
PADDING_X_MIN: 0,
|
||||||
PADDING_X_MAX: 100,
|
PADDING_X_MAX: 100,
|
||||||
|
|
||||||
// Vertical offset for range inputs
|
// Vertical offset for range inputs
|
||||||
@@ -48,6 +47,24 @@ export const IMAGE_SETTINGS = {
|
|||||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||||
|
|
||||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||||
|
|
||||||
|
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||||
|
|
||||||
|
DEFAULT_HUE: 0,
|
||||||
|
HUE_MIN: 0,
|
||||||
|
HUE_MAX: 360,
|
||||||
|
|
||||||
|
DEFAULT_CHROMA: 100,
|
||||||
|
CHROMA_MIN: 0,
|
||||||
|
CHROMA_MAX: 300,
|
||||||
|
|
||||||
|
DEFAULT_BRIGHTNESS: 100,
|
||||||
|
BRIGHTNESS_MIN: 0,
|
||||||
|
BRIGHTNESS_MAX: 100,
|
||||||
|
|
||||||
|
DEFAULT_CONTRAST: 100,
|
||||||
|
CONTRAST_MIN: 50,
|
||||||
|
CONTRAST_MAX: 150,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SlideDirection = {
|
export const SlideDirection = {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||||
import { PANEL_SETTINGS } from "$lib/constants";
|
import { IMAGE_SETTINGS } from "$lib/constants";
|
||||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
import { uploadService } from "$services/uploadService";
|
||||||
|
import { imageState } from "$states/image.svelte";
|
||||||
import { themeState } from "$states/theme.svelte";
|
import { themeState } from "$states/theme.svelte";
|
||||||
import { onMount, type Snippet } from "svelte";
|
import { onMount, type Snippet } from "svelte";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
@@ -18,7 +19,12 @@
|
|||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
let defaultImage = await uploadService.fromUrl(IMAGE_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||||
|
if (defaultImage.ok) {
|
||||||
|
imageState.fullImage = defaultImage.data;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load default image");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type UploadResult =
|
||||||
|
| { ok: true; data: string; error?: never }
|
||||||
|
| { ok: false; error: string; data?: never };
|
||||||
|
|
||||||
|
export const uploadService = {
|
||||||
|
async _process(file: File): Promise<UploadResult> {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return { ok: false, error: "Not an image" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
const img = new Image();
|
||||||
|
img.src = base64;
|
||||||
|
await img.decode();
|
||||||
|
|
||||||
|
return { ok: true, data: base64 };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "File damage or unsupported" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fromFile(file: File | undefined): Promise<UploadResult> {
|
||||||
|
if (!file) return { ok: false, error: "No file" };
|
||||||
|
return this._process(file);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fromClipboard(event: ClipboardEvent): Promise<UploadResult> {
|
||||||
|
const file = event.clipboardData?.files[0];
|
||||||
|
return this.fromFile(file);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fromUrl(url: string): Promise<UploadResult> {
|
||||||
|
if (!url) return { ok: false, error: "No url" };
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return { ok: false, error: "Invalid url" };
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], "image", { type: blob.type });
|
||||||
|
return this._process(file);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Invalid url, or CORS error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface Rect {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageConfig = {
|
||||||
|
fullImage: string | null;
|
||||||
|
croppedImage: HTMLImageElement | undefined;
|
||||||
|
cropRect: Rect;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createState(): ImageConfig & { reset: () => void } {
|
||||||
|
let fullImage = $state<string | null>(null);
|
||||||
|
let croppedImage = $state<HTMLImageElement | undefined>(undefined);
|
||||||
|
let crop: Rect = $state({ left: 0, top: 0, right: 0, bottom: 0 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fullImage(): string | null {
|
||||||
|
return fullImage;
|
||||||
|
},
|
||||||
|
set fullImage(image: string) {
|
||||||
|
if (image) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
fullImage = image;
|
||||||
|
croppedImage = img;
|
||||||
|
};
|
||||||
|
img.src = image;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get croppedImage() {
|
||||||
|
return croppedImage;
|
||||||
|
},
|
||||||
|
get cropRect() {
|
||||||
|
return crop;
|
||||||
|
},
|
||||||
|
set cropRect(rect: Rect) {
|
||||||
|
crop = rect;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
fullImage = null;
|
||||||
|
croppedImage = undefined;
|
||||||
|
crop = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageState = createState();
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
export type ImageConfig = {
|
|
||||||
image: HTMLImageElement | undefined;
|
|
||||||
imageLink: string;
|
|
||||||
imageReady: boolean;
|
|
||||||
cropLeft: number;
|
|
||||||
cropTop: number;
|
|
||||||
cropRight: number;
|
|
||||||
cropBottom: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ImageConfigState {
|
|
||||||
image = $state<HTMLImageElement | undefined>(undefined);
|
|
||||||
imageLink = $state("");
|
|
||||||
imageReady = $state(false);
|
|
||||||
cropLeft = $state(0);
|
|
||||||
cropTop = $state(0);
|
|
||||||
cropRight = $state(0);
|
|
||||||
cropBottom = $state(0);
|
|
||||||
|
|
||||||
#currentAbortController: AbortController | null = null;
|
|
||||||
|
|
||||||
private cleanup() {
|
|
||||||
if (this.#currentAbortController) {
|
|
||||||
this.#currentAbortController.abort();
|
|
||||||
this.#currentAbortController = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.image) {
|
|
||||||
this.image.onload = null;
|
|
||||||
this.image.onerror = null;
|
|
||||||
this.image.src = "";
|
|
||||||
this.image = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadImageByLink(link: string): Promise<void> {
|
|
||||||
this.cleanup();
|
|
||||||
|
|
||||||
this.imageReady = false;
|
|
||||||
this.imageLink = link;
|
|
||||||
|
|
||||||
this.#currentAbortController = new AbortController();
|
|
||||||
const { signal } = this.#currentAbortController;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
|
|
||||||
const onFinished = () => {
|
|
||||||
img.onload = null;
|
|
||||||
img.onerror = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
onFinished();
|
|
||||||
this.image = img;
|
|
||||||
this.imageReady = true;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
onFinished();
|
|
||||||
this.imageReady = false;
|
|
||||||
reject(new Error(`Failed to load image: ${link}`));
|
|
||||||
};
|
|
||||||
|
|
||||||
signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
onFinished();
|
|
||||||
img.src = "";
|
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
img.src = link;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.cleanup();
|
|
||||||
this.imageLink = "";
|
|
||||||
this.imageReady = false;
|
|
||||||
this.cropLeft = 0;
|
|
||||||
this.cropTop = 0;
|
|
||||||
this.cropRight = 0;
|
|
||||||
this.cropBottom = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const imageConfigState = new ImageConfigState();
|
|
||||||
@@ -6,11 +6,17 @@ exports[`Application Constants Logic > Global Contract (Snapshot) > should match
|
|||||||
"IMAGE_SETTINGS": {
|
"IMAGE_SETTINGS": {
|
||||||
"BRIGHTNESS_MAX": 100,
|
"BRIGHTNESS_MAX": 100,
|
||||||
"BRIGHTNESS_MIN": 0,
|
"BRIGHTNESS_MIN": 0,
|
||||||
|
"CHROMA_MAX": 300,
|
||||||
|
"CHROMA_MIN": 0,
|
||||||
"CONTRAST_MAX": 150,
|
"CONTRAST_MAX": 150,
|
||||||
"CONTRAST_MIN": 50,
|
"CONTRAST_MIN": 50,
|
||||||
"DEFAULT_BACKGROUND_IMAGE": "./backgrounds/b1.jpg",
|
"DEFAULT_BACKGROUND_IMAGE": "./backgrounds/b1.jpg",
|
||||||
"DEFAULT_BRIGHTNESS": 100,
|
"DEFAULT_BRIGHTNESS": 100,
|
||||||
|
"DEFAULT_CHROMA": 100,
|
||||||
"DEFAULT_CONTRAST": 100,
|
"DEFAULT_CONTRAST": 100,
|
||||||
|
"DEFAULT_HUE": 0,
|
||||||
|
"HUE_MAX": 360,
|
||||||
|
"HUE_MIN": 0,
|
||||||
"MAX_FILE_SIZE": 10485760,
|
"MAX_FILE_SIZE": 10485760,
|
||||||
"SUPPORTED_FORMATS": [
|
"SUPPORTED_FORMATS": [
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
|
|||||||
@@ -1,216 +1,14 @@
|
|||||||
import { PANEL_SETTINGS } from "$lib/constants";
|
|
||||||
import { imageState } from "$states/image.svelte";
|
import { imageState } from "$states/image.svelte";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
type ImageEventHandler = ((this: HTMLImageElement, ev?: Event) => void) | null;
|
|
||||||
type ImageErrorEventHandler =
|
|
||||||
| ((
|
|
||||||
this: HTMLImageElement,
|
|
||||||
ev?: string | Event,
|
|
||||||
source?: string,
|
|
||||||
lineno?: number,
|
|
||||||
colno?: number,
|
|
||||||
error?: Error,
|
|
||||||
) => void)
|
|
||||||
| null;
|
|
||||||
|
|
||||||
let lastOnload: ImageEventHandler = null;
|
|
||||||
let lastOnerror: ImageErrorEventHandler = null;
|
|
||||||
|
|
||||||
vi.stubGlobal(
|
|
||||||
"Image",
|
|
||||||
class {
|
|
||||||
_onload: ImageEventHandler = null;
|
|
||||||
_onerror: ImageErrorEventHandler = null;
|
|
||||||
_src: string = "";
|
|
||||||
crossOrigin: string = "";
|
|
||||||
|
|
||||||
set src(val: string) {
|
|
||||||
this._src = val;
|
|
||||||
if (val.includes("error")) {
|
|
||||||
setTimeout(
|
|
||||||
() => this._onerror?.call(this as unknown as HTMLImageElement, new Event("error")),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setTimeout(
|
|
||||||
() => this._onload?.call(this as unknown as HTMLImageElement, new Event("load")),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get src() {
|
|
||||||
return this._src;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onload(val: ImageEventHandler) {
|
|
||||||
this._onload = val;
|
|
||||||
if (val) lastOnload = val;
|
|
||||||
}
|
|
||||||
get onload() {
|
|
||||||
return this._onload;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onerror(val: ImageErrorEventHandler) {
|
|
||||||
this._onerror = val;
|
|
||||||
if (val) lastOnerror = val;
|
|
||||||
}
|
|
||||||
get onerror() {
|
|
||||||
return this._onerror;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("imageState", () => {
|
describe("imageState", () => {
|
||||||
beforeEach(() => {
|
afterEach(() => {
|
||||||
lastOnload = null;
|
|
||||||
lastOnerror = null;
|
|
||||||
imageState.reset();
|
imageState.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create new instance with default values", () => {
|
it("should create new instance with default values", () => {
|
||||||
const newState = new imageState();
|
expect(imageState.fullImage).toBeNull();
|
||||||
expect(newState.image).toBeUndefined();
|
expect(imageState.croppedImage).toBeUndefined();
|
||||||
expect(newState.imageLink).toBe("");
|
expect(imageState.cropRect).toStrictEqual({ left: 0, top: 0, right: 0, bottom: 0 });
|
||||||
expect(newState.imageReady).toBe(false);
|
|
||||||
expect(newState.cropLeft).toBe(0);
|
|
||||||
expect(newState.cropTop).toBe(0);
|
|
||||||
expect(newState.cropRight).toBe(0);
|
|
||||||
expect(newState.cropBottom).toBe(0);
|
|
||||||
newState.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should initialize with default background image", async () => {
|
|
||||||
await imageState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
|
||||||
|
|
||||||
expect(imageState.imageReady).toBe(true);
|
|
||||||
expect(imageState.image).toBeDefined();
|
|
||||||
expect(imageState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle manual image upload correctly", async () => {
|
|
||||||
const testLink = "https://example.com/test.png";
|
|
||||||
const uploadPromise = imageState.uploadImageByLink(testLink);
|
|
||||||
|
|
||||||
expect(imageState.imageReady).toBe(false);
|
|
||||||
|
|
||||||
await uploadPromise;
|
|
||||||
expect(imageState.imageReady).toBe(true);
|
|
||||||
expect(imageState.imageLink).toBe(testLink);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reset state to defaults", async () => {
|
|
||||||
await imageState.uploadImageByLink("some-image.png");
|
|
||||||
imageState.cropLeft = 100;
|
|
||||||
|
|
||||||
imageState.reset();
|
|
||||||
|
|
||||||
expect(imageState.imageReady).toBe(false);
|
|
||||||
expect(imageState.imageLink).toBe("");
|
|
||||||
expect(imageState.cropLeft).toBe(0);
|
|
||||||
expect(imageState.image).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image loading error", async () => {
|
|
||||||
await expect(imageState.uploadImageByLink("error-link")).rejects.toThrow(
|
|
||||||
"Failed to load image",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(imageState.imageReady).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should abort previous upload when new upload starts", async () => {
|
|
||||||
const upload1 = imageState.uploadImageByLink("test1.jpg");
|
|
||||||
const upload2 = imageState.uploadImageByLink("test2.jpg");
|
|
||||||
|
|
||||||
await expect(upload1).rejects.toThrow("Aborted");
|
|
||||||
await expect(upload2).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
expect(imageState.imageLink).toBe("test2.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cleanup previous image before loading new one", async () => {
|
|
||||||
await imageState.uploadImageByLink("test1.jpg");
|
|
||||||
const firstImage = imageState.image;
|
|
||||||
|
|
||||||
await imageState.uploadImageByLink("test2.jpg");
|
|
||||||
|
|
||||||
expect(firstImage?.onload).toBeNull();
|
|
||||||
expect(firstImage?.onerror).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set crop values", () => {
|
|
||||||
imageState.cropLeft = 10;
|
|
||||||
imageState.cropTop = 20;
|
|
||||||
imageState.cropRight = 30;
|
|
||||||
imageState.cropBottom = 40;
|
|
||||||
|
|
||||||
expect(imageState.cropLeft).toBe(10);
|
|
||||||
expect(imageState.cropTop).toBe(20);
|
|
||||||
expect(imageState.cropRight).toBe(30);
|
|
||||||
expect(imageState.cropBottom).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cleanup image event handlers on reset", async () => {
|
|
||||||
await imageState.uploadImageByLink("test.jpg");
|
|
||||||
const img = imageState.image;
|
|
||||||
|
|
||||||
imageState.reset();
|
|
||||||
|
|
||||||
expect(img?.onload).toBeNull();
|
|
||||||
expect(img?.onerror).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should abort ongoing upload on reset", async () => {
|
|
||||||
const upload = imageState.uploadImageByLink("test.jpg");
|
|
||||||
|
|
||||||
imageState.reset();
|
|
||||||
|
|
||||||
await expect(upload).rejects.toThrow("Aborted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cleanup resources on destroy", async () => {
|
|
||||||
await imageState.uploadImageByLink("test.jpg");
|
|
||||||
const img = imageState.image;
|
|
||||||
|
|
||||||
imageState.destroy();
|
|
||||||
|
|
||||||
expect(imageState.image).toBeUndefined();
|
|
||||||
expect(img?.onload).toBeNull();
|
|
||||||
expect(img?.onerror).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should abort ongoing upload on destroy", async () => {
|
|
||||||
const upload = imageState.uploadImageByLink("test.jpg");
|
|
||||||
|
|
||||||
imageState.destroy();
|
|
||||||
|
|
||||||
await expect(upload).rejects.toThrow("Aborted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cover aborted onload branch", async () => {
|
|
||||||
const promise = imageState.uploadImageByLink("test.png");
|
|
||||||
|
|
||||||
imageState.destroy();
|
|
||||||
|
|
||||||
if (lastOnload) {
|
|
||||||
lastOnload.call(new Image() as HTMLImageElement, new Event("load"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow();
|
|
||||||
expect(imageState.imageReady).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cover aborted onerror branch", async () => {
|
|
||||||
const promise = imageState.uploadImageByLink("test.png");
|
|
||||||
|
|
||||||
imageState.destroy();
|
|
||||||
|
|
||||||
if (lastOnerror) {
|
|
||||||
lastOnerror.call(new Image() as HTMLImageElement, new Event("load"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow();
|
|
||||||
expect(imageState.imageReady).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user