style: add eslint and prettier, fix style errors
This commit is contained in:
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -1,65 +1,65 @@
|
||||
name: Deploy SvelteKit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "tsconfig.json"
|
||||
- "svelte.config.js"
|
||||
- "vite.config.js"
|
||||
- ".github/workflows/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Clean _app directory
|
||||
run: |
|
||||
rm -rf build/_app
|
||||
touch build/.nojekyll
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./build
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
name: Deploy SvelteKit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "tsconfig.json"
|
||||
- "svelte.config.js"
|
||||
- "vite.config.js"
|
||||
- ".github/workflows/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Clean _app directory
|
||||
run: |
|
||||
rm -rf build/_app
|
||||
touch build/.nojekyll
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./build
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+20
-20
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"extends": ["stylelint-config-recommended", "stylelint-config-html/svelte"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignoreFiles": [
|
||||
".github/**/*",
|
||||
".svelte-kit/**/*",
|
||||
"build/**/*",
|
||||
"coverage/**/*",
|
||||
"dist/**/*",
|
||||
"node_modules/**/*",
|
||||
"reference/**/*"
|
||||
]
|
||||
}
|
||||
{
|
||||
"extends": ["stylelint-config-recommended", "stylelint-config-html/svelte"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignoreFiles": [
|
||||
".github/**/*",
|
||||
".svelte-kit/**/*",
|
||||
"build/**/*",
|
||||
"coverage/**/*",
|
||||
"dist/**/*",
|
||||
"node_modules/**/*",
|
||||
"reference/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import path from "node:path";
|
||||
import ts from "typescript-eslint";
|
||||
import svelteConfig from "./svelte.config.js";
|
||||
|
||||
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ["reference/"],
|
||||
},
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: [".svelte"],
|
||||
parser: ts.parser,
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+7189
-5723
File diff suppressed because it is too large
Load Diff
+73
-61
@@ -1,61 +1,73 @@
|
||||
{
|
||||
"name": "twitch-panels",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint:css": "stylelint '**/*.{css,svelte}",
|
||||
"check:css": "node scripts/css-vars.js",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.89",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^25.1.0",
|
||||
"@vitest/coverage-istanbul": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"glob": "^13.0.2",
|
||||
"jsdom": "^28.0.0",
|
||||
"konva": "^10.2.0",
|
||||
"stylelint": "^17.2.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recommended": "^18.0.0",
|
||||
"svelte": "^5.48.2",
|
||||
"svelte-check": "^4.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-canvas-mock": "^1.1.3",
|
||||
"web-animations-js": "^2.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/cropperjs": "^1.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"cropperjs": "^2.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jszip": "^3.10.1",
|
||||
"svelte-konva": "^1.0.1",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "twitch-panels",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint:css": "stylelint '**/*.{css,svelte}",
|
||||
"check:css": "node scripts/css-vars.js",
|
||||
"check:all": "npm run check && npm run check:css && npm run lint:css && npm run lint && npm run test:run",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@iconify-json/lucide": "^1.2.89",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20",
|
||||
"@vitest/coverage-istanbul": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"glob": "^13.0.2",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"konva": "^10.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"stylelint": "^17.2.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recommended": "^18.0.0",
|
||||
"svelte": "^5.48.2",
|
||||
"svelte-check": "^4.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-canvas-mock": "^1.1.3",
|
||||
"web-animations-js": "^2.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/cropperjs": "^1.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"cropperjs": "^2.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jszip": "^3.10.1",
|
||||
"svelte-konva": "^1.0.1",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+330
-330
@@ -1,330 +1,330 @@
|
||||
# Функциональный план (Product & UX Roadmap)
|
||||
|
||||
## Twitch Panels Creator
|
||||
|
||||
**Анализ версии:** 0.0.1
|
||||
**Дата анализа:** 2025-02-09
|
||||
**Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js
|
||||
|
||||
---
|
||||
|
||||
## 1. Текущие возможности (глазами пользователя)
|
||||
|
||||
### ✅ Реализованный функционал
|
||||
|
||||
#### 1.1 Управление текстами панелей
|
||||
|
||||
**Файлы:** [`src/states/texts.svelte.ts`](src/states/texts.svelte.ts), [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
|
||||
|
||||
- Добавление текстовых панелей через инпут + кнопку/Enter
|
||||
- Редактирование текста инлайн (двойной клик или прямое редактирование)
|
||||
- Удаление отдельных текстов
|
||||
- Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение)
|
||||
- **Приоритет:** High - это основной рабочий поток
|
||||
|
||||
#### 1.2 Система превью панелей
|
||||
|
||||
**Файлы:** [`src/components/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte), [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte), [`src/components/panel/PreviewAll.svelte`](src/components/panel/PreviewAll.svelte)
|
||||
|
||||
- Просмотр превью текущей панели с настройками текста
|
||||
- Навигация между панелями (стрелки + индикатор "N / M")
|
||||
- Просмотр всех панелей одновременно (в скрытом контейнере для экспорта)
|
||||
- Плавные переходы между панелями (fly transition)
|
||||
- **Приоритет:** High - критично для UX
|
||||
|
||||
#### 1.3 Экспорт панелей
|
||||
|
||||
**Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts)
|
||||
|
||||
- Экспорт отдельной панели в PNG (320x100px)
|
||||
- Массовый экспорт всех панелей в ZIP-архив
|
||||
- Использование file-saver и JSZip
|
||||
- **Приоритет:** High - финальная цель пользователя
|
||||
|
||||
#### 1.4 Система фоновых изображений (частичная)
|
||||
|
||||
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte), [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte)
|
||||
|
||||
- Загрузка изображения по URL (только UI кнопки)
|
||||
- Визуальный интерфейс для обрезки (crop box с handles)
|
||||
- Настройки яркости и контраста (только UI слайдеры)
|
||||
- Автозагрузка дефолтного фона при старте
|
||||
- **Приоритет:** Low - нерабочая система
|
||||
|
||||
#### 1.5 Базовый UI/UX
|
||||
|
||||
**Файлы:** [`src/components/layout/Card.svelte`](src/components/layout/Card.svelte), [`src/components/ui/Button.svelte`](src/components/ui/Button.svelte), [`src/components/ui/RangeSlider.svelte`](src/components/ui/RangeSlider.svelte), [`src/components/ui/ColorPicker.svelte`](src/components/ui/ColorPicker.svelte), [`src/components/ui/SelectFont.svelte`](src/components/ui/SelectFont.svelte), [`src/components/ui/Alignment.svelte`](src/components/ui/Alignment.svelte)
|
||||
|
||||
- Карточная система группировки
|
||||
- Кнопки 5 типов (primary, secondary, outline, danger, mini)
|
||||
- Слайдеры с отображением значения
|
||||
- Цветовой пикер
|
||||
- Выбор шрифта из 8 системных
|
||||
- Три кнопки выравнивания
|
||||
- Темная/светлая тема
|
||||
- **Приоритет:** Medium - работает, но можно улучшить
|
||||
|
||||
---
|
||||
|
||||
## 2. Low-hanging fruit (Ближайшие улучшения)
|
||||
|
||||
### 2.1 Завершение системы изображений (High Priority)
|
||||
|
||||
**Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки.
|
||||
|
||||
**Требуемые правки:**
|
||||
|
||||
1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts):
|
||||
- Drag & drop поддержка
|
||||
- Paste из буфера обмена
|
||||
- Загрузка по URL с валидацией
|
||||
- Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max)
|
||||
- **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>`
|
||||
|
||||
2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte):
|
||||
- Инициализация cropper.js на canvas
|
||||
- Привязка crop box к данным состояния
|
||||
- Реализовать drag & drop для перемещения
|
||||
- Реализовать resize через 8 handles
|
||||
- **Контракт:** `onCropChange(crop: { left, top, right, bottom })`
|
||||
|
||||
3. **Применить фильтры яркости/контраста**:
|
||||
- В [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts) добавить `brightness: number`, `contrast: number`
|
||||
- Применить CSS filters к Konva Image в [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte)
|
||||
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
|
||||
|
||||
4. **Сохранить обрезанное изображение**:
|
||||
- Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными
|
||||
- Использовать canvas для actual cropping
|
||||
|
||||
### 2.2 Валидация и состояния загрузки (Medium Priority)
|
||||
|
||||
**Тексты:**
|
||||
|
||||
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить валидацию длины (max 100 chars из [`src/lib/constants.ts`](src/lib/constants.ts):32)
|
||||
- Показать ошибку при превышении длины
|
||||
- Добавить счетчик символов "3/100"
|
||||
|
||||
**Изображения:**
|
||||
|
||||
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL
|
||||
- Показать ошибку при неудачной загрузке
|
||||
- Валидация формата/размера до загрузки
|
||||
|
||||
**Экспорт:**
|
||||
|
||||
- [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()`
|
||||
- Отключить кнопки во время экспорта
|
||||
- Показать toast уведомление об успехе/ошибке
|
||||
|
||||
### 2.3 Пустые состояния (Medium Priority)
|
||||
|
||||
**Списки:**
|
||||
|
||||
- [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte): когда `textsState.texts.length === 0`, показать красивый empty state вместо пустого UL
|
||||
- [`src/components/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte): уже есть empty state, но можно улучшить (иконка + текст + CTA)
|
||||
|
||||
**Изображение:**
|
||||
|
||||
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией
|
||||
|
||||
### 2.4 Улучшение доступности (Medium Priority)
|
||||
|
||||
**Файлы для правок:**
|
||||
|
||||
- Все кнопки уже имеют `aria-label` ✅
|
||||
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить `aria-describedby` для валидации
|
||||
- [`src/components/text/TextInlineEdit.svelte`](src/components/text/TextInlineEdit.svelte): добавить `aria-label` на delete button (уже есть)
|
||||
- Добавить `role="region"` и `aria-label` на карточки
|
||||
- Убедиться, что фокусный порядок логичен (Tab navigation)
|
||||
|
||||
---
|
||||
|
||||
## 3. Масштабирование (Крупные модули/интеграции)
|
||||
|
||||
### 3.1 Сохранение и загрузка проектов (High Priority)
|
||||
|
||||
**Проблема:** Нет persistence, пользователь теряет данные при перезагрузке.
|
||||
|
||||
**Решение:**
|
||||
|
||||
1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):**
|
||||
- `saveProject(project: Project): Promise<void>` → localStorage
|
||||
- `loadProject(id: string): Project | null`
|
||||
- `listProjects(): ProjectInfo[]`
|
||||
- `deleteProject(id: string)`
|
||||
- **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }`
|
||||
|
||||
2. **Добавить UI для управления проектами:**
|
||||
- [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку
|
||||
- [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый):
|
||||
- Список сохраненных проектов
|
||||
- Создание/переименование/удаление
|
||||
- Экспорт/импорт JSON (для бэкапа)
|
||||
|
||||
3. **Автосохранение:**
|
||||
- В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save
|
||||
- Восстановление при загрузке страницы
|
||||
|
||||
**User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам.
|
||||
|
||||
### 3.2 Расширенные настройки текста (Medium Priority)
|
||||
|
||||
**Файлы для модификации:** [`src/states/textConfig.svelte.ts`](src/states/textConfig.svelte.ts), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
|
||||
|
||||
Добавить:
|
||||
|
||||
- **Тень текста:** `textShadow: { offsetX, offsetY, blur, color }`
|
||||
- **Прозрачность:** `opacity: number` (0-100)
|
||||
- **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее)
|
||||
- **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte
|
||||
|
||||
**Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva.
|
||||
|
||||
### 3.3 Расширенные настройки изображений (Medium Priority)
|
||||
|
||||
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte)
|
||||
|
||||
Добавить:
|
||||
|
||||
- **Фильтры:** blur, sepia, saturate, grayscale
|
||||
- **Наложение:** возможность добавить второй слой изображения
|
||||
- **Режимы наложения:** multiply, screen, overlay (CSS blend modes)
|
||||
|
||||
**Приоритет:** Medium - улучшает креативность, но сложная реализация.
|
||||
|
||||
### 3.4 Пресеты и шаблоны (Low Priority)
|
||||
|
||||
**Новые файлы:**
|
||||
|
||||
- [`src/services/templateService.ts`](src/services/templateService.ts)
|
||||
- [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte)
|
||||
|
||||
**Функционал:**
|
||||
|
||||
- Предустановленные шаблоны (разные стили текста/изображений)
|
||||
- Сохранение пользовательских пресетов
|
||||
- Применение пресета к текущему проекту
|
||||
|
||||
**Приоритет:** Low - nice-to-have, не критично для MVP.
|
||||
|
||||
### 3.5 Интеграция с Twitch API (Future)
|
||||
|
||||
**Возможные интеграции:**
|
||||
|
||||
- Прямая загрузка панелей на Twitch через API
|
||||
- Синхронизация с существующими панелями
|
||||
- Планирование публикации
|
||||
|
||||
**Приоритет:** Low - требует OAuth, сложная интеграция.
|
||||
|
||||
---
|
||||
|
||||
## 4. User Retention (Повторное использование)
|
||||
|
||||
### 4.1 Сохранение проектов (см. 3.1) - HIGH
|
||||
|
||||
### 4.2 Настройки пользователя (Medium Priority)
|
||||
|
||||
**Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15)
|
||||
- Сохранение предпочитаемого шрифта
|
||||
- Сохранение последних использованных цветов
|
||||
- **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts)
|
||||
|
||||
### 4.3 Экспорт в разные форматы (Medium Priority)
|
||||
|
||||
**Текущее состояние:** Только PNG.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- Экспорт в JPEG (с настройкой качества)
|
||||
- Экспорт в WebP (современный формат)
|
||||
- Экспорт в PDF (для печати)
|
||||
- **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService)
|
||||
|
||||
**Приоритет:** Medium - увеличивает полезность.
|
||||
|
||||
### 4.4 История изменений (Low Priority)
|
||||
|
||||
**Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts)
|
||||
|
||||
**Функционал:**
|
||||
|
||||
- Undo/Redo для текстов и настроек
|
||||
- Хранение N последних состояний
|
||||
- Горячие клавиши Ctrl+Z / Ctrl+Y
|
||||
|
||||
**Приоритет:** Low - удобно, но не обязательно.
|
||||
|
||||
### 4.5 Совместная работа (Future)
|
||||
|
||||
**Сложная интеграция:**
|
||||
|
||||
- Share link с состоянием (закодировать в URL)
|
||||
- Real-time collaboration (WebSocket)
|
||||
- Комментарии/ревью
|
||||
|
||||
**Приоритет:** Very Low - далеко от MVP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Приоритизация по фазам
|
||||
|
||||
### Фаза 1: Завершение базового функционала (2-3 недели)
|
||||
|
||||
**Цель:** Приложение должно работать end-to-end
|
||||
|
||||
1. Полная реализация системы изображений (2.1) - **High**
|
||||
2. Сохранение проектов (3.1) - **High**
|
||||
3. Валидация и состояния загрузки (2.2) - **Medium**
|
||||
4. Пустые состояния (2.3) - **Medium**
|
||||
5. Настройки пользователя (4.2) - **Medium**
|
||||
|
||||
**Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом.
|
||||
|
||||
### Фаза 2: Улучшение UX и расширение (3-4 недели)
|
||||
|
||||
1. Расширенные настройки текста (3.2) - **Medium**
|
||||
2. Расширенные настройки изображений (3.3) - **Medium**
|
||||
3. Экспорт в разные форматы (4.3) - **Medium**
|
||||
4. Улучшение доступности (2.4) - **Medium**
|
||||
5. Пресеты и шаблоны (3.4) - **Low**
|
||||
|
||||
**Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили.
|
||||
|
||||
### Фаза 3: Продвинутые фичи (4+ недели)
|
||||
|
||||
1. История изменений (4.4) - **Low**
|
||||
2. Интеграция с Twitch API (3.5) - **Low**
|
||||
3. Совместная работа (4.5) - **Very Low**
|
||||
|
||||
---
|
||||
|
||||
## 6. Метрики успеха
|
||||
|
||||
- **Активация:** >70% пользователей создают хотя бы 1 панель
|
||||
- **Сохранение:** >30% пользователей сохраняют проект
|
||||
- **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель
|
||||
- **Время на создание:** <2 минут для набора из 5 панелей
|
||||
- **Retention week 1:** >40% возвращаются
|
||||
|
||||
---
|
||||
|
||||
## 7. Риски и ограничения
|
||||
|
||||
1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte)
|
||||
2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях
|
||||
3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений
|
||||
4. **Cropper.js:** Требует доработки для работы с canvas/Konva
|
||||
|
||||
---
|
||||
|
||||
**Следующие шаги:**
|
||||
|
||||
1. Утвердить функциональный план
|
||||
2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md)
|
||||
3. Начать реализацию Фазы 1 (начиная с imageService)
|
||||
# Функциональный план (Product & UX Roadmap)
|
||||
|
||||
## Twitch Panels Creator
|
||||
|
||||
**Анализ версии:** 0.0.1
|
||||
**Дата анализа:** 2025-02-09
|
||||
**Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js
|
||||
|
||||
---
|
||||
|
||||
## 1. Текущие возможности (глазами пользователя)
|
||||
|
||||
### ✅ Реализованный функционал
|
||||
|
||||
#### 1.1 Управление текстами панелей
|
||||
|
||||
**Файлы:** [`src/states/texts.svelte.ts`](src/states/texts.svelte.ts), [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
|
||||
|
||||
- Добавление текстовых панелей через инпут + кнопку/Enter
|
||||
- Редактирование текста инлайн (двойной клик или прямое редактирование)
|
||||
- Удаление отдельных текстов
|
||||
- Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение)
|
||||
- **Приоритет:** High - это основной рабочий поток
|
||||
|
||||
#### 1.2 Система превью панелей
|
||||
|
||||
**Файлы:** [`src/components/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte), [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte), [`src/components/panel/PreviewAll.svelte`](src/components/panel/PreviewAll.svelte)
|
||||
|
||||
- Просмотр превью текущей панели с настройками текста
|
||||
- Навигация между панелями (стрелки + индикатор "N / M")
|
||||
- Просмотр всех панелей одновременно (в скрытом контейнере для экспорта)
|
||||
- Плавные переходы между панелями (fly transition)
|
||||
- **Приоритет:** High - критично для UX
|
||||
|
||||
#### 1.3 Экспорт панелей
|
||||
|
||||
**Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts)
|
||||
|
||||
- Экспорт отдельной панели в PNG (320x100px)
|
||||
- Массовый экспорт всех панелей в ZIP-архив
|
||||
- Использование file-saver и JSZip
|
||||
- **Приоритет:** High - финальная цель пользователя
|
||||
|
||||
#### 1.4 Система фоновых изображений (частичная)
|
||||
|
||||
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte), [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte)
|
||||
|
||||
- Загрузка изображения по URL (только UI кнопки)
|
||||
- Визуальный интерфейс для обрезки (crop box с handles)
|
||||
- Настройки яркости и контраста (только UI слайдеры)
|
||||
- Автозагрузка дефолтного фона при старте
|
||||
- **Приоритет:** Low - нерабочая система
|
||||
|
||||
#### 1.5 Базовый UI/UX
|
||||
|
||||
**Файлы:** [`src/components/layout/Card.svelte`](src/components/layout/Card.svelte), [`src/components/ui/Button.svelte`](src/components/ui/Button.svelte), [`src/components/ui/RangeSlider.svelte`](src/components/ui/RangeSlider.svelte), [`src/components/ui/ColorPicker.svelte`](src/components/ui/ColorPicker.svelte), [`src/components/ui/SelectFont.svelte`](src/components/ui/SelectFont.svelte), [`src/components/ui/Alignment.svelte`](src/components/ui/Alignment.svelte)
|
||||
|
||||
- Карточная система группировки
|
||||
- Кнопки 5 типов (primary, secondary, outline, danger, mini)
|
||||
- Слайдеры с отображением значения
|
||||
- Цветовой пикер
|
||||
- Выбор шрифта из 8 системных
|
||||
- Три кнопки выравнивания
|
||||
- Темная/светлая тема
|
||||
- **Приоритет:** Medium - работает, но можно улучшить
|
||||
|
||||
---
|
||||
|
||||
## 2. Low-hanging fruit (Ближайшие улучшения)
|
||||
|
||||
### 2.1 Завершение системы изображений (High Priority)
|
||||
|
||||
**Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки.
|
||||
|
||||
**Требуемые правки:**
|
||||
|
||||
1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts):
|
||||
- Drag & drop поддержка
|
||||
- Paste из буфера обмена
|
||||
- Загрузка по URL с валидацией
|
||||
- Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max)
|
||||
- **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>`
|
||||
|
||||
2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte):
|
||||
- Инициализация cropper.js на canvas
|
||||
- Привязка crop box к данным состояния
|
||||
- Реализовать drag & drop для перемещения
|
||||
- Реализовать resize через 8 handles
|
||||
- **Контракт:** `onCropChange(crop: { left, top, right, bottom })`
|
||||
|
||||
3. **Применить фильтры яркости/контраста**:
|
||||
- В [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts) добавить `brightness: number`, `contrast: number`
|
||||
- Применить CSS filters к Konva Image в [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte)
|
||||
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
|
||||
|
||||
4. **Сохранить обрезанное изображение**:
|
||||
- Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными
|
||||
- Использовать canvas для actual cropping
|
||||
|
||||
### 2.2 Валидация и состояния загрузки (Medium Priority)
|
||||
|
||||
**Тексты:**
|
||||
|
||||
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить валидацию длины (max 100 chars из [`src/lib/constants.ts`](src/lib/constants.ts):32)
|
||||
- Показать ошибку при превышении длины
|
||||
- Добавить счетчик символов "3/100"
|
||||
|
||||
**Изображения:**
|
||||
|
||||
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL
|
||||
- Показать ошибку при неудачной загрузке
|
||||
- Валидация формата/размера до загрузки
|
||||
|
||||
**Экспорт:**
|
||||
|
||||
- [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()`
|
||||
- Отключить кнопки во время экспорта
|
||||
- Показать toast уведомление об успехе/ошибке
|
||||
|
||||
### 2.3 Пустые состояния (Medium Priority)
|
||||
|
||||
**Списки:**
|
||||
|
||||
- [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte): когда `textsState.texts.length === 0`, показать красивый empty state вместо пустого UL
|
||||
- [`src/components/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte): уже есть empty state, но можно улучшить (иконка + текст + CTA)
|
||||
|
||||
**Изображение:**
|
||||
|
||||
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией
|
||||
|
||||
### 2.4 Улучшение доступности (Medium Priority)
|
||||
|
||||
**Файлы для правок:**
|
||||
|
||||
- Все кнопки уже имеют `aria-label` ✅
|
||||
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить `aria-describedby` для валидации
|
||||
- [`src/components/text/TextInlineEdit.svelte`](src/components/text/TextInlineEdit.svelte): добавить `aria-label` на delete button (уже есть)
|
||||
- Добавить `role="region"` и `aria-label` на карточки
|
||||
- Убедиться, что фокусный порядок логичен (Tab navigation)
|
||||
|
||||
---
|
||||
|
||||
## 3. Масштабирование (Крупные модули/интеграции)
|
||||
|
||||
### 3.1 Сохранение и загрузка проектов (High Priority)
|
||||
|
||||
**Проблема:** Нет persistence, пользователь теряет данные при перезагрузке.
|
||||
|
||||
**Решение:**
|
||||
|
||||
1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):**
|
||||
- `saveProject(project: Project): Promise<void>` → localStorage
|
||||
- `loadProject(id: string): Project | null`
|
||||
- `listProjects(): ProjectInfo[]`
|
||||
- `deleteProject(id: string)`
|
||||
- **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }`
|
||||
|
||||
2. **Добавить UI для управления проектами:**
|
||||
- [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку
|
||||
- [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый):
|
||||
- Список сохраненных проектов
|
||||
- Создание/переименование/удаление
|
||||
- Экспорт/импорт JSON (для бэкапа)
|
||||
|
||||
3. **Автосохранение:**
|
||||
- В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save
|
||||
- Восстановление при загрузке страницы
|
||||
|
||||
**User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам.
|
||||
|
||||
### 3.2 Расширенные настройки текста (Medium Priority)
|
||||
|
||||
**Файлы для модификации:** [`src/states/textConfig.svelte.ts`](src/states/textConfig.svelte.ts), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
|
||||
|
||||
Добавить:
|
||||
|
||||
- **Тень текста:** `textShadow: { offsetX, offsetY, blur, color }`
|
||||
- **Прозрачность:** `opacity: number` (0-100)
|
||||
- **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее)
|
||||
- **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte
|
||||
|
||||
**Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva.
|
||||
|
||||
### 3.3 Расширенные настройки изображений (Medium Priority)
|
||||
|
||||
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte)
|
||||
|
||||
Добавить:
|
||||
|
||||
- **Фильтры:** blur, sepia, saturate, grayscale
|
||||
- **Наложение:** возможность добавить второй слой изображения
|
||||
- **Режимы наложения:** multiply, screen, overlay (CSS blend modes)
|
||||
|
||||
**Приоритет:** Medium - улучшает креативность, но сложная реализация.
|
||||
|
||||
### 3.4 Пресеты и шаблоны (Low Priority)
|
||||
|
||||
**Новые файлы:**
|
||||
|
||||
- [`src/services/templateService.ts`](src/services/templateService.ts)
|
||||
- [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte)
|
||||
|
||||
**Функционал:**
|
||||
|
||||
- Предустановленные шаблоны (разные стили текста/изображений)
|
||||
- Сохранение пользовательских пресетов
|
||||
- Применение пресета к текущему проекту
|
||||
|
||||
**Приоритет:** Low - nice-to-have, не критично для MVP.
|
||||
|
||||
### 3.5 Интеграция с Twitch API (Future)
|
||||
|
||||
**Возможные интеграции:**
|
||||
|
||||
- Прямая загрузка панелей на Twitch через API
|
||||
- Синхронизация с существующими панелями
|
||||
- Планирование публикации
|
||||
|
||||
**Приоритет:** Low - требует OAuth, сложная интеграция.
|
||||
|
||||
---
|
||||
|
||||
## 4. User Retention (Повторное использование)
|
||||
|
||||
### 4.1 Сохранение проектов (см. 3.1) - HIGH
|
||||
|
||||
### 4.2 Настройки пользователя (Medium Priority)
|
||||
|
||||
**Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15)
|
||||
- Сохранение предпочитаемого шрифта
|
||||
- Сохранение последних использованных цветов
|
||||
- **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts)
|
||||
|
||||
### 4.3 Экспорт в разные форматы (Medium Priority)
|
||||
|
||||
**Текущее состояние:** Только PNG.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- Экспорт в JPEG (с настройкой качества)
|
||||
- Экспорт в WebP (современный формат)
|
||||
- Экспорт в PDF (для печати)
|
||||
- **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService)
|
||||
|
||||
**Приоритет:** Medium - увеличивает полезность.
|
||||
|
||||
### 4.4 История изменений (Low Priority)
|
||||
|
||||
**Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts)
|
||||
|
||||
**Функционал:**
|
||||
|
||||
- Undo/Redo для текстов и настроек
|
||||
- Хранение N последних состояний
|
||||
- Горячие клавиши Ctrl+Z / Ctrl+Y
|
||||
|
||||
**Приоритет:** Low - удобно, но не обязательно.
|
||||
|
||||
### 4.5 Совместная работа (Future)
|
||||
|
||||
**Сложная интеграция:**
|
||||
|
||||
- Share link с состоянием (закодировать в URL)
|
||||
- Real-time collaboration (WebSocket)
|
||||
- Комментарии/ревью
|
||||
|
||||
**Приоритет:** Very Low - далеко от MVP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Приоритизация по фазам
|
||||
|
||||
### Фаза 1: Завершение базового функционала (2-3 недели)
|
||||
|
||||
**Цель:** Приложение должно работать end-to-end
|
||||
|
||||
1. Полная реализация системы изображений (2.1) - **High**
|
||||
2. Сохранение проектов (3.1) - **High**
|
||||
3. Валидация и состояния загрузки (2.2) - **Medium**
|
||||
4. Пустые состояния (2.3) - **Medium**
|
||||
5. Настройки пользователя (4.2) - **Medium**
|
||||
|
||||
**Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом.
|
||||
|
||||
### Фаза 2: Улучшение UX и расширение (3-4 недели)
|
||||
|
||||
1. Расширенные настройки текста (3.2) - **Medium**
|
||||
2. Расширенные настройки изображений (3.3) - **Medium**
|
||||
3. Экспорт в разные форматы (4.3) - **Medium**
|
||||
4. Улучшение доступности (2.4) - **Medium**
|
||||
5. Пресеты и шаблоны (3.4) - **Low**
|
||||
|
||||
**Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили.
|
||||
|
||||
### Фаза 3: Продвинутые фичи (4+ недели)
|
||||
|
||||
1. История изменений (4.4) - **Low**
|
||||
2. Интеграция с Twitch API (3.5) - **Low**
|
||||
3. Совместная работа (4.5) - **Very Low**
|
||||
|
||||
---
|
||||
|
||||
## 6. Метрики успеха
|
||||
|
||||
- **Активация:** >70% пользователей создают хотя бы 1 панель
|
||||
- **Сохранение:** >30% пользователей сохраняют проект
|
||||
- **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель
|
||||
- **Время на создание:** <2 минут для набора из 5 панелей
|
||||
- **Retention week 1:** >40% возвращаются
|
||||
|
||||
---
|
||||
|
||||
## 7. Риски и ограничения
|
||||
|
||||
1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte)
|
||||
2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях
|
||||
3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений
|
||||
4. **Cropper.js:** Требует доработки для работы с canvas/Konva
|
||||
|
||||
---
|
||||
|
||||
**Следующие шаги:**
|
||||
|
||||
1. Утвердить функциональный план
|
||||
2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md)
|
||||
3. Начать реализацию Фазы 1 (начиная с imageService)
|
||||
|
||||
+1145
-1145
File diff suppressed because it is too large
Load Diff
+288
-250
@@ -1,250 +1,288 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Twitch Panels Creator</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
|
||||
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="left-column">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Тексты панелей</h2>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" id="panelTextInput" placeholder="Введите текст..." class="text-input" />
|
||||
<button id="addTextBtn" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="textsList" class="texts-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Настройки текста</h2>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Размер</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="fontSize" min="12" max="48" value="18" class="slider" />
|
||||
<span class="value-display" id="fontSizeValue">18</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Шрифт</label>
|
||||
<select id="fontFamily" class="select-input">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Цвет</label>
|
||||
<div class="setting-control">
|
||||
<input type="color" id="textColor" value="#ffffff" class="color-input" />
|
||||
<span class="color-value" id="textColorValue">#ffffff</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Выравнивание</label>
|
||||
<div class="alignment-buttons">
|
||||
<button class="align-btn active" data-align="left">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="17" y1="10" x2="3" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="17" y1="18" x2="3" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="align-btn" data-align="center">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="10" x2="6" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="18" y1="18" x2="6" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="align-btn" data-align="right">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="21" y1="10" x2="7" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="21" y1="18" x2="7" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Отступы</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" />
|
||||
<span class="value-display" id="sidePaddingValue">10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Смещение</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="centerOffset" min="-50" max="50" value="0" class="slider" />
|
||||
<span class="value-display" id="centerOffsetValue">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="right-column">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Фоновое изображение</h2>
|
||||
|
||||
<div class="crop-editor">
|
||||
<div class="crop-canvas-container" id="cropCanvasContainer">
|
||||
<canvas id="cropCanvas" class="crop-canvas"></canvas>
|
||||
<div class="crop-box" id="cropBox">
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crop-controls">
|
||||
<button id="uploadBgBtn" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
Загрузить
|
||||
</button>
|
||||
<input type="file" id="bgImageInput" accept="image/*" style="display: none" />
|
||||
|
||||
<button id="resetCropBtn" class="btn btn-outline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="1 4 1 10 7 10"></polyline>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||||
</svg>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Яркость</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="bgBrightness" min="50" max="150" value="100" class="slider" />
|
||||
<span class="value-display" id="bgBrightnessValue">100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Контраст</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="bgContrast" min="50" max="150" value="100" class="slider" />
|
||||
<span class="value-display" id="bgContrastValue">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2>
|
||||
<div class="panel-nav">
|
||||
<button id="prevPanel" class="nav-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="panelIndicator" class="panel-indicator">0 / 0</span>
|
||||
<button id="nextPanel" class="nav-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-viewer">
|
||||
<div id="panelDisplay" class="panel-display">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="9" y1="9" x2="15" y2="9"></line>
|
||||
</svg>
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button id="downloadCurrentBtn" class="btn btn-primary" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
<button id="downloadAllBtn" class="btn btn-outline" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
|
||||
<polyline points="7 11 12 16 17 11"></polyline>
|
||||
<line x1="12" y1="16" x2="12" y2="4"></line>
|
||||
</svg>
|
||||
Скачать все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Twitch Panels Creator</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
|
||||
<svg
|
||||
class="sun-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg
|
||||
class="moon-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="left-column">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Тексты панелей</h2>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="panelTextInput"
|
||||
placeholder="Введите текст..."
|
||||
class="text-input"
|
||||
/>
|
||||
<button id="addTextBtn" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="textsList" class="texts-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Настройки текста</h2>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Размер</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="fontSize" min="12" max="48" value="18" class="slider" />
|
||||
<span class="value-display" id="fontSizeValue">18</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Шрифт</label>
|
||||
<select id="fontFamily" class="select-input">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Цвет</label>
|
||||
<div class="setting-control">
|
||||
<input type="color" id="textColor" value="#ffffff" class="color-input" />
|
||||
<span class="color-value" id="textColorValue">#ffffff</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Выравнивание</label>
|
||||
<div class="alignment-buttons">
|
||||
<button class="align-btn active" data-align="left">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="17" y1="10" x2="3" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="17" y1="18" x2="3" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="align-btn" data-align="center">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="10" x2="6" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="18" y1="18" x2="6" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="align-btn" data-align="right">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="21" y1="10" x2="7" y2="10"></line>
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||
<line x1="21" y1="18" x2="7" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Отступы</label>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" />
|
||||
<span class="value-display" id="sidePaddingValue">10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Смещение</label>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
type="range"
|
||||
id="centerOffset"
|
||||
min="-50"
|
||||
max="50"
|
||||
value="0"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="value-display" id="centerOffsetValue">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="right-column">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Фоновое изображение</h2>
|
||||
|
||||
<div class="crop-editor">
|
||||
<div class="crop-canvas-container" id="cropCanvasContainer">
|
||||
<canvas id="cropCanvas" class="crop-canvas"></canvas>
|
||||
<div class="crop-box" id="cropBox">
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crop-controls">
|
||||
<button id="uploadBgBtn" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
Загрузить
|
||||
</button>
|
||||
<input type="file" id="bgImageInput" accept="image/*" style="display: none" />
|
||||
|
||||
<button id="resetCropBtn" class="btn btn-outline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="1 4 1 10 7 10"></polyline>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||||
</svg>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Яркость</label>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
type="range"
|
||||
id="bgBrightness"
|
||||
min="50"
|
||||
max="150"
|
||||
value="100"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="value-display" id="bgBrightnessValue">100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Контраст</label>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
type="range"
|
||||
id="bgContrast"
|
||||
min="50"
|
||||
max="150"
|
||||
value="100"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="value-display" id="bgContrastValue">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2>
|
||||
<div class="panel-nav">
|
||||
<button id="prevPanel" class="nav-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="panelIndicator" class="panel-indicator">0 / 0</span>
|
||||
<button id="nextPanel" class="nav-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-viewer">
|
||||
<div id="panelDisplay" class="panel-display">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="9" y1="9" x2="15" y2="9"></line>
|
||||
</svg>
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button id="downloadCurrentBtn" class="btn btn-primary" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
<button id="downloadAllBtn" class="btn btn-outline" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
|
||||
<polyline points="7 11 12 16 17 11"></polyline>
|
||||
<line x1="12" y1="16" x2="12" y2="4"></line>
|
||||
</svg>
|
||||
Скачать все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+625
-570
File diff suppressed because it is too large
Load Diff
+763
-763
File diff suppressed because it is too large
Load Diff
+61
-61
@@ -1,61 +1,61 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { globSync } from "glob";
|
||||
|
||||
const includePatterns = ["src/**/*.svelte", "src/**/*.css"];
|
||||
|
||||
const files = globSync(includePatterns);
|
||||
|
||||
const defined = new Set();
|
||||
const used = new Set();
|
||||
const usageLocations = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g);
|
||||
for (const m of defMatches) {
|
||||
defined.add(m[0]);
|
||||
}
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g);
|
||||
for (const match of varMatches) {
|
||||
const varName = match[0].slice(4, -1).trim();
|
||||
used.add(varName);
|
||||
|
||||
usageLocations.push({
|
||||
file,
|
||||
line: idx + 1,
|
||||
column: match.index + 1,
|
||||
varName,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let hasUndefined = false;
|
||||
const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName));
|
||||
|
||||
undefinedErrors.forEach(({ file, line, column, varName }) => {
|
||||
console.error(`❌ ${file}:${line}:${column} — не определена CSS-переменная "${varName}"`);
|
||||
hasUndefined = true;
|
||||
});
|
||||
|
||||
const unused = [...defined].filter((varName) => !used.has(varName));
|
||||
if (unused.length > 0) {
|
||||
console.warn("\n⚠️ Объявлены, но нигде не используются:");
|
||||
unused.forEach((varName) => console.warn(` ${varName}`));
|
||||
}
|
||||
|
||||
if (hasUndefined) {
|
||||
console.error("\n❌ Найдены неопределённые переменные. Исправьте их.");
|
||||
process.exit(1);
|
||||
} else {
|
||||
if (unused.length === 0) {
|
||||
console.log("\n✅ Все CSS-переменные определены и используются.");
|
||||
} else {
|
||||
console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения).");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
import { readFileSync } from "fs";
|
||||
import { globSync } from "glob";
|
||||
|
||||
const includePatterns = ["src/**/*.svelte", "src/**/*.css"];
|
||||
|
||||
const files = globSync(includePatterns);
|
||||
|
||||
const defined = new Set();
|
||||
const used = new Set();
|
||||
const usageLocations = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g);
|
||||
for (const m of defMatches) {
|
||||
defined.add(m[0]);
|
||||
}
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g);
|
||||
for (const match of varMatches) {
|
||||
const varName = match[0].slice(4, -1).trim();
|
||||
used.add(varName);
|
||||
|
||||
usageLocations.push({
|
||||
file,
|
||||
line: idx + 1,
|
||||
column: match.index + 1,
|
||||
varName,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let hasUndefined = false;
|
||||
const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName));
|
||||
|
||||
undefinedErrors.forEach(({ file, line, column, varName }) => {
|
||||
console.error(`❌ ${file}:${line}:${column} — не определена CSS-переменная "${varName}"`);
|
||||
hasUndefined = true;
|
||||
});
|
||||
|
||||
const unused = [...defined].filter((varName) => !used.has(varName));
|
||||
if (unused.length > 0) {
|
||||
console.warn("\n⚠️ Объявлены, но нигде не используются:");
|
||||
unused.forEach((varName) => console.warn(` ${varName}`));
|
||||
}
|
||||
|
||||
if (hasUndefined) {
|
||||
console.error("\n❌ Найдены неопределённые переменные. Исправьте их.");
|
||||
process.exit(1);
|
||||
} else {
|
||||
if (unused.length === 0) {
|
||||
console.log("\n✅ Все CSS-переменные определены и используются.");
|
||||
} else {
|
||||
console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения).");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
+79
-79
@@ -1,79 +1,79 @@
|
||||
:root {
|
||||
--brand-main: #86efac;
|
||||
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
||||
|
||||
--action-lightness: 0.6;
|
||||
--action-chroma: 0.18;
|
||||
--hover-step: -0.08;
|
||||
|
||||
--danger-base: #ef4444;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.85 0.04 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 h);
|
||||
|
||||
--action-primary: oklch(from var(--brand-main) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l - var(--hover-step)) c h);
|
||||
--action-secondary: oklch(from var(--brand-alt) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l - var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.25 0.02 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.45 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.9 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.8 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
|
||||
--radius: 8px;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--action-lightness: 0.5;
|
||||
--action-chroma: 0.18;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.2 0.03 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 h);
|
||||
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l + var(--hover-step)) c h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l + var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.94 0.01 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.22 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
line-height: 1.5;
|
||||
}
|
||||
:root {
|
||||
--brand-main: #86efac;
|
||||
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
||||
|
||||
--action-lightness: 0.6;
|
||||
--action-chroma: 0.18;
|
||||
--hover-step: -0.08;
|
||||
|
||||
--danger-base: #ef4444;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.85 0.04 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 h);
|
||||
|
||||
--action-primary: oklch(from var(--brand-main) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l - var(--hover-step)) c h);
|
||||
--action-secondary: oklch(from var(--brand-alt) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l - var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.25 0.02 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.45 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.9 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.8 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
|
||||
--radius: 8px;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--action-lightness: 0.5;
|
||||
--action-chroma: 0.18;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.2 0.03 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 h);
|
||||
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l + var(--hover-step)) c h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l + var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.94 0.01 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
|
||||
/* --text-action: oklch(from var(--brand-main) 0.12 0.02 h); */
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.22 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
<script lang="ts">
|
||||
let active = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="crop-canvas-container">
|
||||
<canvas class="crop-canvas"></canvas>
|
||||
<div class="crop-box" class:active>
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.crop-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 5;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.crop-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--action-primary);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||
cursor: move;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crop-box.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid var(--action-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.crop-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.crop-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.crop-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.crop-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.crop-handle.w {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.crop-handle.e {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: e-resize;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
let active = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="crop-canvas-container">
|
||||
<canvas class="crop-canvas"></canvas>
|
||||
<div class="crop-box" class:active>
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.crop-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 5;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.crop-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--action-primary);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||
cursor: move;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crop-box.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid var(--action-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.crop-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.crop-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.crop-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.crop-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.crop-handle.w {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.crop-handle.e {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: e-resize;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
||||
// import Upload from "@lucide/svelte/icons/upload";
|
||||
import Pencil from "~icons/lucide/pencil";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Upload from "~icons/lucide/upload";
|
||||
import CropInline from "./CropInline.svelte";
|
||||
|
||||
let brightness = $state(100);
|
||||
let contrast = $state(100);
|
||||
</script>
|
||||
|
||||
<Card title="Фоновое изображение">
|
||||
<div class="crop-editor">
|
||||
<CropInline />
|
||||
|
||||
<div class="crop-controls">
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
||||
</div>
|
||||
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Яркость">
|
||||
<RangeSlider bind:value={brightness} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Контраст">
|
||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.crop-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.crop-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
||||
// import Upload from "@lucide/svelte/icons/upload";
|
||||
import Pencil from "~icons/lucide/pencil";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Upload from "~icons/lucide/upload";
|
||||
import CropInline from "./CropInline.svelte";
|
||||
|
||||
let brightness = $state(100);
|
||||
let contrast = $state(100);
|
||||
</script>
|
||||
|
||||
<Card title="Фоновое изображение">
|
||||
<div class="crop-editor">
|
||||
<CropInline />
|
||||
|
||||
<div class="crop-controls">
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
||||
</div>
|
||||
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Яркость">
|
||||
<RangeSlider bind:value={brightness} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Контраст">
|
||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.crop-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.crop-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import Moon from "~icons/lucide/moon";
|
||||
import Sun from "~icons/lucide/sun";
|
||||
|
||||
function toggleTheme() {
|
||||
themeState.toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
||||
<Sun class="sun-icon" />
|
||||
<Moon class="moon-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
|
||||
color: var(--text-action);
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:global(.theme-toggle svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
:global(.sun-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
:global(.moon-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .sun-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .moon-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import Moon from "~icons/lucide/moon";
|
||||
import Sun from "~icons/lucide/sun";
|
||||
|
||||
function toggleTheme() {
|
||||
themeState.toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
||||
<Sun class="sun-icon" />
|
||||
<Moon class="moon-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
|
||||
color: var(--text-action);
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:global(.theme-toggle svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
:global(.sun-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
:global(.moon-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .sun-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .moon-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string | Snippet;
|
||||
children: Snippet;
|
||||
titleSnippet?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore block_empty -->
|
||||
{#snippet emptySnippet()}{/snippet}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">
|
||||
{#if typeof title === "string"}
|
||||
{title}
|
||||
{:else}
|
||||
{@render title()}
|
||||
{/if}
|
||||
</h2>
|
||||
<div class="card-snippet">
|
||||
{@render titleSnippet()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-snippet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string | Snippet;
|
||||
children: Snippet;
|
||||
titleSnippet?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet emptySnippet()}{/snippet}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">
|
||||
{#if typeof title === "string"}
|
||||
{title}
|
||||
{:else}
|
||||
{@render title()}
|
||||
{/if}
|
||||
</h2>
|
||||
<div class="card-snippet">
|
||||
{@render titleSnippet()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-snippet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="panel-bar">
|
||||
<ImageManager />
|
||||
<PreviewManager />
|
||||
</div>
|
||||
<script lang="ts">
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="panel-bar">
|
||||
<ImageManager />
|
||||
<PreviewManager />
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="settings-grid">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="settings-grid">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
children: Snippet;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
let { label, children, noLabel = false }: Props = $props();
|
||||
|
||||
let tag = $derived(noLabel ? "div" : "label");
|
||||
</script>
|
||||
|
||||
<svelte:element this={tag} class="setting-row">
|
||||
<div class="setting-label">{label}</div>
|
||||
<div class="setting-control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
children: Snippet;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
let { label, children, noLabel = false }: Props = $props();
|
||||
|
||||
let tag = $derived(noLabel ? "div" : "label");
|
||||
</script>
|
||||
|
||||
<svelte:element this={tag} class="setting-row">
|
||||
<div class="setting-label">{label}</div>
|
||||
<div class="setting-control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="text-bar">
|
||||
<TextManager />
|
||||
<TextConfig />
|
||||
</div>
|
||||
<script lang="ts">
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="text-bar">
|
||||
<TextManager />
|
||||
<TextConfig />
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
stage: Stage | undefined;
|
||||
}
|
||||
|
||||
let { text, stage = $bindable() }: Props = $props();
|
||||
|
||||
let x = $derived(textConfigState.paddingX);
|
||||
let y = $derived(10 + textConfigState.offsetY);
|
||||
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
||||
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
|
||||
</script>
|
||||
|
||||
<Stage width={320} height={100} bind:this={stage}>
|
||||
<Layer>
|
||||
<Image image={imageConfigState.image}></Image>
|
||||
<Text
|
||||
{text}
|
||||
{x}
|
||||
{y}
|
||||
{width}
|
||||
{height}
|
||||
fontSize={textConfigState.fontSize}
|
||||
fill={textConfigState.color}
|
||||
fontFamily={textConfigState.fontFamily}
|
||||
align={textConfigState.align}
|
||||
wrap="none"
|
||||
ellipsis={true}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
<script lang="ts">
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
stage: Stage | undefined;
|
||||
}
|
||||
|
||||
let { text, stage = $bindable() }: Props = $props();
|
||||
|
||||
let x = $derived(textConfigState.paddingX);
|
||||
let y = $derived(10 + textConfigState.offsetY);
|
||||
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
||||
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
|
||||
</script>
|
||||
|
||||
<Stage width={320} height={100} bind:this={stage}>
|
||||
<Layer>
|
||||
<Image image={imageConfigState.image}></Image>
|
||||
<Text
|
||||
{text}
|
||||
{x}
|
||||
{y}
|
||||
{width}
|
||||
{height}
|
||||
fontSize={textConfigState.fontSize}
|
||||
fill={textConfigState.color}
|
||||
fontFamily={textConfigState.fontFamily}
|
||||
align={textConfigState.align}
|
||||
wrap="none"
|
||||
ellipsis={true}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
|
||||
import Preview from "./Preview.svelte";
|
||||
</script>
|
||||
|
||||
<div class="outside-display">
|
||||
{#each textsState.texts as { text, id }, idx (id)}
|
||||
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
||||
{:else}
|
||||
<p>No texts to preview</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outside-display {
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
|
||||
import Preview from "./Preview.svelte";
|
||||
</script>
|
||||
|
||||
<div class="outside-display">
|
||||
{#each textsState.texts as { text, id }, idx (id)}
|
||||
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
||||
{:else}
|
||||
<p>No texts to preview</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outside-display {
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
||||
import ChevronLeft from "~icons/lucide/chevron-left";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
direction: SlideDirectionType;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
||||
|
||||
let isFirst = $derived(current === 0);
|
||||
let isLast = $derived(current === max - 1);
|
||||
|
||||
function prev() {
|
||||
if (current > 0) current--;
|
||||
direction = SlideDirection.PREV;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (current < max - 1) current++;
|
||||
direction = SlideDirection.NEXT;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button icon={ChevronLeft} ariaLabel="Previous slide" type="mini" onclick={prev} disabled={isFirst} />
|
||||
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
||||
|
||||
<style>
|
||||
.panel-indicator {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
||||
import ChevronLeft from "~icons/lucide/chevron-left";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
direction: SlideDirectionType;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
||||
|
||||
let isFirst = $derived(current === 0);
|
||||
let isLast = $derived(current === max - 1);
|
||||
|
||||
function prev() {
|
||||
if (current > 0) current--;
|
||||
direction = SlideDirection.PREV;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (current < max - 1) current++;
|
||||
direction = SlideDirection.NEXT;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
icon={ChevronLeft}
|
||||
ariaLabel="Previous slide"
|
||||
type="mini"
|
||||
onclick={prev}
|
||||
disabled={isFirst}
|
||||
/>
|
||||
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
||||
|
||||
<style>
|
||||
.panel-indicator {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,136 +1,144 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
||||
import { downloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import Download from "~icons/lucide/download";
|
||||
import StickyNote from "~icons/lucide/sticky-note";
|
||||
import Preview from "./Preview.svelte";
|
||||
import PreviewAll from "./PreviewAll.svelte";
|
||||
import PreviewControls from "./PreviewControls.svelte";
|
||||
|
||||
let current: number = $state(0);
|
||||
let direction: SlideDirectionType = $state("next");
|
||||
|
||||
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
|
||||
|
||||
$effect(() => {
|
||||
if (textsState.texts.length === 0) {
|
||||
current = 0;
|
||||
} else {
|
||||
if (current > textsState.texts.length - 1) {
|
||||
current = textsState.texts.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function downloadAll() {
|
||||
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
|
||||
filename: text.text,
|
||||
stage: konvaAllStagesState[idx]?.node,
|
||||
}));
|
||||
|
||||
downloadService.downloadAll(downloadItems);
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
let node = konvaStageState.stage?.node;
|
||||
if (node) {
|
||||
downloadService.downloadPanel(node, textsState.texts[current].text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet panelTitle()}
|
||||
Панели <Badge text={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet panelControls()}
|
||||
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||
<div class="panel-viewer">
|
||||
<div class="panel-display">
|
||||
{#if textsState.texts.length}
|
||||
{#key current}
|
||||
{@const text = textsState?.texts[current]?.text}
|
||||
<div
|
||||
class="konva-wrapper"
|
||||
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
||||
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
||||
>
|
||||
<Preview {text} bind:stage={konvaStageState.stage} />
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state" aria-label="Empty texts info">
|
||||
<StickyNote />
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
||||
<Button label="Скачать" ariaLabel="Download current" type="secondary" icon={Download} onclick={downloadCurrent} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<PreviewAll />
|
||||
|
||||
<style>
|
||||
.panel-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-display {
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-state :global(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.konva-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
||||
import { downloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import Download from "~icons/lucide/download";
|
||||
import StickyNote from "~icons/lucide/sticky-note";
|
||||
import Preview from "./Preview.svelte";
|
||||
import PreviewAll from "./PreviewAll.svelte";
|
||||
import PreviewControls from "./PreviewControls.svelte";
|
||||
|
||||
let current: number = $state(0);
|
||||
let direction: SlideDirectionType = $state("next");
|
||||
|
||||
let xDirection = $derived(
|
||||
direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (textsState.texts.length === 0) {
|
||||
current = 0;
|
||||
} else {
|
||||
if (current > textsState.texts.length - 1) {
|
||||
current = textsState.texts.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function downloadAll() {
|
||||
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
|
||||
filename: text.text,
|
||||
stage: konvaAllStagesState[idx]?.node,
|
||||
}));
|
||||
|
||||
downloadService.downloadAll(downloadItems);
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
let node = konvaStageState.stage?.node;
|
||||
if (node) {
|
||||
downloadService.downloadPanel(node, textsState.texts[current].text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet panelTitle()}
|
||||
Панели <Badge text={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet panelControls()}
|
||||
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||
<div class="panel-viewer">
|
||||
<div class="panel-display">
|
||||
{#if textsState.texts.length}
|
||||
{#key current}
|
||||
{@const text = textsState?.texts[current]?.text}
|
||||
<div
|
||||
class="konva-wrapper"
|
||||
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
||||
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
||||
>
|
||||
<Preview {text} bind:stage={konvaStageState.stage} />
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state" aria-label="Empty texts info">
|
||||
<StickyNote />
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
||||
<Button
|
||||
label="Скачать"
|
||||
ariaLabel="Download current"
|
||||
type="secondary"
|
||||
icon={Download}
|
||||
onclick={downloadCurrent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<PreviewAll />
|
||||
|
||||
<style>
|
||||
.panel-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-display {
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-state :global(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.konva-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
</script>
|
||||
|
||||
<Card title="Настройки текста">
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Размер">
|
||||
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Шрифт">
|
||||
<SelectFont bind:value={textConfigState.fontFamily} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Цвет">
|
||||
<ColorPicker bind:value={textConfigState.color} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Выравнивание" noLabel={true}>
|
||||
<Alignment bind:align={textConfigState.align} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Отступы">
|
||||
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Смещение">
|
||||
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</Card>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
</script>
|
||||
|
||||
<Card title="Настройки текста">
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Размер">
|
||||
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Шрифт">
|
||||
<SelectFont bind:value={textConfigState.fontFamily} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Цвет">
|
||||
<ColorPicker bind:value={textConfigState.color} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Выравнивание" noLabel={true}>
|
||||
<Alignment bind:align={textConfigState.align} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Отступы">
|
||||
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Смещение">
|
||||
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</Card>
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { TRANSITION_DURATION } from "$lib/constants";
|
||||
import { fly } from "svelte/transition";
|
||||
import Cross from "~icons/lucide/x";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
id: number;
|
||||
ondelete: (id: number) => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), id, ondelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
||||
<input type="text" bind:value={text} />
|
||||
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.text-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.text-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-item input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-item input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { TRANSITION_DURATION } from "$lib/constants";
|
||||
import { fly } from "svelte/transition";
|
||||
import Cross from "~icons/lucide/x";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
id: number;
|
||||
ondelete: (id: number) => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), id, ondelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
||||
<input type="text" bind:value={text} />
|
||||
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.text-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.text-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-item input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-item input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
onenter: () => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
||||
|
||||
function handleKeyboard(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
onenter();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
bind:value={text}
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="Введите текст..."
|
||||
onkeydown={handleKeyboard}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.text-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
onenter: () => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
||||
|
||||
function handleKeyboard(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
onenter();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
bind:value={text}
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="Введите текст..."
|
||||
onkeydown={handleKeyboard}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.text-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import Plus from "~icons/lucide/plus";
|
||||
import TextInlineEdit from "./TextInlineEdit.svelte";
|
||||
import TextInput from "./TextInput.svelte";
|
||||
|
||||
let text: string = $state("");
|
||||
|
||||
function addText() {
|
||||
textsState.addText(text);
|
||||
text = "";
|
||||
}
|
||||
|
||||
function deleteText(id: number) {
|
||||
textsState.removeText(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Тексты панелей">
|
||||
<InputGroup>
|
||||
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
||||
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
||||
</InputGroup>
|
||||
<ul class="texts-list">
|
||||
{#each textsState.texts as { text, id } (id)}
|
||||
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.texts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import Plus from "~icons/lucide/plus";
|
||||
import TextInlineEdit from "./TextInlineEdit.svelte";
|
||||
import TextInput from "./TextInput.svelte";
|
||||
|
||||
let text: string = $state("");
|
||||
|
||||
function addText() {
|
||||
textsState.addText(text);
|
||||
text = "";
|
||||
}
|
||||
|
||||
function deleteText(id: number) {
|
||||
textsState.removeText(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Тексты панелей">
|
||||
<InputGroup>
|
||||
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
||||
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
||||
</InputGroup>
|
||||
<ul class="texts-list">
|
||||
{#each textsState.texts as { text, id } (id)}
|
||||
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.texts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { TextAlign, type TextAlignType } from "$lib/constants";
|
||||
import TextAlignCenter from "~icons/lucide/text-align-center";
|
||||
import TextAlignEnd from "~icons/lucide/text-align-end";
|
||||
import TextAlignStart from "~icons/lucide/text-align-start";
|
||||
|
||||
interface Props {
|
||||
align: TextAlignType;
|
||||
}
|
||||
|
||||
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alignment-group">
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.LEFT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.LEFT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignStart /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.CENTER}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.CENTER}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignCenter /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.RIGHT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.RIGHT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignEnd /></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alignment-group {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alignment-button {
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alignment-button:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.alignment-item {
|
||||
flex-grow: 1;
|
||||
& input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&:first-child .alignment-button {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
&:last-child .alignment-button {
|
||||
border-top-right-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
|
||||
.alignment-item input:checked + .alignment-button {
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { TextAlign, type TextAlignType } from "$lib/constants";
|
||||
import TextAlignCenter from "~icons/lucide/text-align-center";
|
||||
import TextAlignEnd from "~icons/lucide/text-align-end";
|
||||
import TextAlignStart from "~icons/lucide/text-align-start";
|
||||
|
||||
interface Props {
|
||||
align: TextAlignType;
|
||||
}
|
||||
|
||||
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alignment-group">
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.LEFT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.LEFT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignStart /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.CENTER}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.CENTER}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignCenter /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.RIGHT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.RIGHT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignEnd /></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alignment-group {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alignment-button {
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alignment-button:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.alignment-item {
|
||||
flex-grow: 1;
|
||||
& input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&:first-child .alignment-button {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
&:last-child .alignment-button {
|
||||
border-top-right-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
|
||||
.alignment-item input:checked + .alignment-button {
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string | number;
|
||||
}
|
||||
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge">{text}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string | number;
|
||||
}
|
||||
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge">{text}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
+115
-115
@@ -1,115 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
icon: Component;
|
||||
ariaLabel: string;
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||
extra?: "grow";
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
ariaLabel,
|
||||
onclick = () => {},
|
||||
disabled = false,
|
||||
label = "",
|
||||
type = "primary",
|
||||
extra,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
class:btn-primary={type === "primary"}
|
||||
class:btn-secondary={type === "secondary"}
|
||||
class:btn-outline={type === "outline"}
|
||||
class:btn-danger={type === "danger"}
|
||||
class:btn-mini={type === "mini"}
|
||||
class:grow={extra === "grow"}
|
||||
{disabled}
|
||||
{onclick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Icon />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
--btn-main: var(--action-primary);
|
||||
--btn-hover: var(--action-primary-hover);
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
background-color: var(--btn-main);
|
||||
color: oklch(from var(--btn-main) 99% 0.05 h);
|
||||
}
|
||||
|
||||
.btn :global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--btn-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--btn-main: var(--action-secondary);
|
||||
--btn-hover: var(--action-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
--btn-main: var(--danger-base);
|
||||
}
|
||||
|
||||
.btn-mini {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-mini:hover:not(:disabled) {
|
||||
border-color: var(--border-main);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
icon: Component;
|
||||
ariaLabel: string;
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||
extra?: "grow";
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
ariaLabel,
|
||||
onclick = () => {},
|
||||
disabled = false,
|
||||
label = "",
|
||||
type = "primary",
|
||||
extra,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
class:btn-primary={type === "primary"}
|
||||
class:btn-secondary={type === "secondary"}
|
||||
class:btn-outline={type === "outline"}
|
||||
class:btn-danger={type === "danger"}
|
||||
class:btn-mini={type === "mini"}
|
||||
class:grow={extra === "grow"}
|
||||
{disabled}
|
||||
{onclick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Icon />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
--btn-main: var(--action-primary);
|
||||
--btn-hover: var(--action-primary-hover);
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
background-color: var(--btn-main);
|
||||
color: var(--text-action);
|
||||
}
|
||||
|
||||
.btn :global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--btn-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--btn-main: var(--action-secondary);
|
||||
--btn-hover: var(--action-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
--btn-main: var(--danger-base);
|
||||
}
|
||||
|
||||
.btn-mini {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-mini:hover:not(:disabled) {
|
||||
border-color: var(--border-main);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { HexColor } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
value: HexColor;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="color" bind:value class="color-input" />
|
||||
<span class="color-value">{value}</span>
|
||||
|
||||
<style>
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--surface-main);
|
||||
padding: 0px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input:hover {
|
||||
border-color: var(--action-primary);
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { HexColor } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
value: HexColor;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="color" bind:value class="color-input" />
|
||||
<span class="color-value">{value}</span>
|
||||
|
||||
<style>
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--surface-main);
|
||||
padding: 0px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input:hover {
|
||||
border-color: var(--action-primary);
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
||||
<span class="value-display">{value}</span>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-control);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.value-display {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
||||
<span class="value-display">{value}</span>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-control);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.value-display {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<select class="select-input" bind:value>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
|
||||
<style>
|
||||
.select-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<select class="select-input" bind:value>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
|
||||
<style>
|
||||
.select-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
+77
-77
@@ -1,77 +1,77 @@
|
||||
// Application Constants
|
||||
|
||||
// ===== PANEL SETTINGS =====
|
||||
export const PANEL_SETTINGS = {
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 200,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
} as const;
|
||||
|
||||
// ===== TYPOGRAPHY =====
|
||||
export const TYPOGRAPHY = {
|
||||
FONT_FAMILY_DEFAULT: "Arial",
|
||||
FONT_FAMILIES: [
|
||||
"Arial",
|
||||
"Verdana",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
"Courier New",
|
||||
"Impact",
|
||||
"Comic Sans MS",
|
||||
"Trebuchet MS",
|
||||
],
|
||||
|
||||
// Font sizes for range inputs
|
||||
FONT_SIZE_MIN: 10,
|
||||
FONT_SIZE_MAX: 72,
|
||||
FONT_SIZE_DEFAULT: 32,
|
||||
|
||||
// Text limits
|
||||
MAX_TEXT_LENGTH: 100,
|
||||
|
||||
// Padding for range inputs
|
||||
PADDING_X_DEFAULT: 10,
|
||||
PADDING_X_MAX: 100,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
VERTICAL_OFFSET_MAX: 100,
|
||||
VERTICAL_OFFSET_MIN: -100,
|
||||
|
||||
// Colors
|
||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||
} as const;
|
||||
|
||||
// ===== IMAGE SETTINGS =====
|
||||
export const IMAGE_SETTINGS = {
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||
} as const;
|
||||
|
||||
export const SlideDirection = {
|
||||
NEXT: "next",
|
||||
PREV: "prev",
|
||||
} as const;
|
||||
|
||||
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
||||
|
||||
export const TextAlign = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
} as const;
|
||||
|
||||
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
||||
|
||||
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
||||
|
||||
export const Theme = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||
|
||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||
// Application Constants
|
||||
|
||||
// ===== PANEL SETTINGS =====
|
||||
export const PANEL_SETTINGS = {
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 200,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
} as const;
|
||||
|
||||
// ===== TYPOGRAPHY =====
|
||||
export const TYPOGRAPHY = {
|
||||
FONT_FAMILY_DEFAULT: "Arial",
|
||||
FONT_FAMILIES: [
|
||||
"Arial",
|
||||
"Verdana",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
"Courier New",
|
||||
"Impact",
|
||||
"Comic Sans MS",
|
||||
"Trebuchet MS",
|
||||
],
|
||||
|
||||
// Font sizes for range inputs
|
||||
FONT_SIZE_MIN: 10,
|
||||
FONT_SIZE_MAX: 72,
|
||||
FONT_SIZE_DEFAULT: 32,
|
||||
|
||||
// Text limits
|
||||
MAX_TEXT_LENGTH: 100,
|
||||
|
||||
// Padding for range inputs
|
||||
PADDING_X_DEFAULT: 10,
|
||||
PADDING_X_MAX: 100,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
VERTICAL_OFFSET_MAX: 100,
|
||||
VERTICAL_OFFSET_MIN: -100,
|
||||
|
||||
// Colors
|
||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||
} as const;
|
||||
|
||||
// ===== IMAGE SETTINGS =====
|
||||
export const IMAGE_SETTINGS = {
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||
} as const;
|
||||
|
||||
export const SlideDirection = {
|
||||
NEXT: "next",
|
||||
PREV: "prev",
|
||||
} as const;
|
||||
|
||||
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
||||
|
||||
export const TextAlign = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
} as const;
|
||||
|
||||
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
||||
|
||||
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
||||
|
||||
export const Theme = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||
|
||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||
|
||||
+37
-37
@@ -1,37 +1,37 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
if (details) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "IMAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "TEXT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CANVAS_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "STORAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
if (details) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "IMAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "TEXT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CANVAS_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "STORAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export type HexColor = `#${string}`;
|
||||
export type HexColor = `#${string}`;
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { AppError } from "$lib/error.types";
|
||||
|
||||
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError || error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, details?: unknown): AppError {
|
||||
return new AppError(message, code, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
import { AppError } from "$lib/error.types";
|
||||
|
||||
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError || error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, details?: unknown): AppError {
|
||||
return new AppError(message, code, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
+35
-35
@@ -1,35 +1,35 @@
|
||||
<script lang="ts">
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import "../app.css";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const newTheme = themeState.current;
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
});
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<AppHeader />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import "../app.css";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const newTheme = themeState.current;
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
});
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<AppHeader />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
|
||||
+23
-23
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||
import TextBar from "$components/layout/TextBar.svelte";
|
||||
</script>
|
||||
|
||||
<div class="main-grid">
|
||||
<TextBar />
|
||||
<PanelBar />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||
import TextBar from "$components/layout/TextBar.svelte";
|
||||
</script>
|
||||
|
||||
<div class="main-grid">
|
||||
<TextBar />
|
||||
<PanelBar />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
import { ImageError } from "$lib/error.types";
|
||||
import { formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import { Stage } from "konva/lib/Stage";
|
||||
|
||||
export type DownloadResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface DownloadItem {
|
||||
filename: string;
|
||||
stage: Stage;
|
||||
}
|
||||
|
||||
export class DownloadService {
|
||||
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
||||
try {
|
||||
const blob = await this.stageToBlob(konvaStage);
|
||||
|
||||
const filename = `${label}.png`;
|
||||
saveAs(blob, filename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения панели");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (let panel of panels) {
|
||||
const blob = await this.stageToBlob(panel.stage);
|
||||
zip.file(`${panel.filename}.png`, blob);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(zipBlob, "panels.zip");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения архива");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения архива"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadService = new DownloadService();
|
||||
import { ImageError } from "$lib/error.types";
|
||||
import { formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import { Stage } from "konva/lib/Stage";
|
||||
|
||||
export type DownloadResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface DownloadItem {
|
||||
filename: string;
|
||||
stage: Stage;
|
||||
}
|
||||
|
||||
export class DownloadService {
|
||||
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
||||
try {
|
||||
const blob = await this.stageToBlob(konvaStage);
|
||||
|
||||
const filename = `${label}.png`;
|
||||
saveAs(blob, filename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения панели");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const panel of panels) {
|
||||
const blob = await this.stageToBlob(panel.stage);
|
||||
zip.file(`${panel.filename}.png`, blob);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(zipBlob, "panels.zip");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения архива");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения архива"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadService = new DownloadService();
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
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();
|
||||
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();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
function createState() {
|
||||
let stage: Stage | undefined = $state(undefined);
|
||||
|
||||
return {
|
||||
get stage(): Stage | undefined {
|
||||
return stage;
|
||||
},
|
||||
set stage(newStage: Stage) {
|
||||
stage = newStage;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const konvaStageState = createState();
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
function createState() {
|
||||
let stage: Stage | undefined = $state(undefined);
|
||||
|
||||
return {
|
||||
get stage(): Stage | undefined {
|
||||
return stage;
|
||||
},
|
||||
set stage(newStage: Stage) {
|
||||
stage = newStage;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const konvaStageState = createState();
|
||||
|
||||
@@ -1,48 +1,44 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const STATE_DATA = Symbol("state-data");
|
||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||
|
||||
type OnlyData<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export interface Persistable<D> {
|
||||
[STATE_DATA]: D;
|
||||
}
|
||||
|
||||
export function withPersistence<D extends object, T extends Persistable<D>>(
|
||||
key: string,
|
||||
state: T,
|
||||
debounceMs = DEBOUNCE_DURATION,
|
||||
): T {
|
||||
if (!browser) return state;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (parsed) {
|
||||
state[STATE_DATA] = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error repairing state for ${key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
|
||||
|
||||
if (debounceMs > 0) {
|
||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
localStorage.setItem(key, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const STATE_DATA = Symbol("state-data");
|
||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||
|
||||
export interface Persistable<D> {
|
||||
[STATE_DATA]: D;
|
||||
}
|
||||
|
||||
export function withPersistence<D extends object, T extends Persistable<D>>(
|
||||
key: string,
|
||||
state: T,
|
||||
debounceMs = DEBOUNCE_DURATION,
|
||||
): T {
|
||||
if (!browser) return state;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (parsed) {
|
||||
state[STATE_DATA] = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error repairing state for ${key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
|
||||
|
||||
if (debounceMs > 0) {
|
||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
localStorage.setItem(key, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
import { type TextAlignType } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export type TextConfig = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: HexColor;
|
||||
align: TextAlignType;
|
||||
paddingX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
function createState() {
|
||||
const defaults: TextConfig = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
let state: TextConfig = $state({ ...defaults });
|
||||
|
||||
return {
|
||||
get fontSize() {
|
||||
return state.fontSize;
|
||||
},
|
||||
set fontSize(value: number) {
|
||||
state.fontSize = value;
|
||||
},
|
||||
get fontFamily() {
|
||||
return state.fontFamily;
|
||||
},
|
||||
set fontFamily(fontFamily: string) {
|
||||
state.fontFamily = fontFamily;
|
||||
},
|
||||
get color() {
|
||||
return state.color;
|
||||
},
|
||||
set color(color: HexColor) {
|
||||
state.color = color;
|
||||
},
|
||||
get align() {
|
||||
return state.align;
|
||||
},
|
||||
set align(align: TextAlignType) {
|
||||
state.align = align;
|
||||
},
|
||||
get paddingX() {
|
||||
return state.paddingX;
|
||||
},
|
||||
set paddingX(paddingX: number) {
|
||||
state.paddingX = paddingX;
|
||||
},
|
||||
get offsetY() {
|
||||
return state.offsetY;
|
||||
},
|
||||
set offsetY(offsetY: number) {
|
||||
state.offsetY = offsetY;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
fontSize: state.fontSize,
|
||||
fontFamily: state.fontFamily,
|
||||
color: state.color,
|
||||
align: state.align,
|
||||
paddingX: state.paddingX,
|
||||
offsetY: state.offsetY,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](newConfig: TextConfig) {
|
||||
state.fontSize = newConfig.fontSize;
|
||||
state.fontFamily = newConfig.fontFamily;
|
||||
state.color = newConfig.color;
|
||||
state.align = newConfig.align;
|
||||
state.paddingX = newConfig.paddingX;
|
||||
state.offsetY = newConfig.offsetY;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textConfigState = withPersistence("text-config", createState());
|
||||
import { type TextAlignType } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export type TextConfig = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: HexColor;
|
||||
align: TextAlignType;
|
||||
paddingX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
function createState() {
|
||||
const defaults: TextConfig = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
const state: TextConfig = $state({ ...defaults });
|
||||
|
||||
return {
|
||||
get fontSize() {
|
||||
return state.fontSize;
|
||||
},
|
||||
set fontSize(value: number) {
|
||||
state.fontSize = value;
|
||||
},
|
||||
get fontFamily() {
|
||||
return state.fontFamily;
|
||||
},
|
||||
set fontFamily(fontFamily: string) {
|
||||
state.fontFamily = fontFamily;
|
||||
},
|
||||
get color() {
|
||||
return state.color;
|
||||
},
|
||||
set color(color: HexColor) {
|
||||
state.color = color;
|
||||
},
|
||||
get align() {
|
||||
return state.align;
|
||||
},
|
||||
set align(align: TextAlignType) {
|
||||
state.align = align;
|
||||
},
|
||||
get paddingX() {
|
||||
return state.paddingX;
|
||||
},
|
||||
set paddingX(paddingX: number) {
|
||||
state.paddingX = paddingX;
|
||||
},
|
||||
get offsetY() {
|
||||
return state.offsetY;
|
||||
},
|
||||
set offsetY(offsetY: number) {
|
||||
state.offsetY = offsetY;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
fontSize: state.fontSize,
|
||||
fontFamily: state.fontFamily,
|
||||
color: state.color,
|
||||
align: state.align,
|
||||
paddingX: state.paddingX,
|
||||
offsetY: state.offsetY,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](newConfig: TextConfig) {
|
||||
state.fontSize = newConfig.fontSize;
|
||||
state.fontFamily = newConfig.fontFamily;
|
||||
state.color = newConfig.color;
|
||||
state.align = newConfig.align;
|
||||
state.paddingX = newConfig.paddingX;
|
||||
state.offsetY = newConfig.offsetY;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textConfigState = withPersistence("text-config", createState());
|
||||
|
||||
+43
-40
@@ -1,40 +1,43 @@
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({ text, id: idx }));
|
||||
|
||||
export function createState() {
|
||||
let texts: Array<TextItem> = $state(defaultTexts);
|
||||
let nextId = $state(defaultTexts.length);
|
||||
|
||||
return {
|
||||
get texts() {
|
||||
return texts;
|
||||
},
|
||||
addText(text: string) {
|
||||
if (text.trim().length === 0) return;
|
||||
texts.push({ text, id: nextId });
|
||||
nextId++;
|
||||
},
|
||||
removeText(id: number) {
|
||||
texts = texts.filter((textItem) => textItem.id !== id);
|
||||
},
|
||||
clear() {
|
||||
texts = [];
|
||||
nextId = 0;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return texts.map(({ text }) => text);
|
||||
},
|
||||
set [STATE_DATA](newTexts: Array<string>) {
|
||||
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
||||
nextId = newTexts.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textsState = withPersistence("texts", createState());
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({
|
||||
text,
|
||||
id: idx,
|
||||
}));
|
||||
|
||||
export function createState() {
|
||||
let texts: Array<TextItem> = $state(defaultTexts);
|
||||
let nextId = $state(defaultTexts.length);
|
||||
|
||||
return {
|
||||
get texts() {
|
||||
return texts;
|
||||
},
|
||||
addText(text: string) {
|
||||
if (text.trim().length === 0) return;
|
||||
texts.push({ text, id: nextId });
|
||||
nextId++;
|
||||
},
|
||||
removeText(id: number) {
|
||||
texts = texts.filter((textItem) => textItem.id !== id);
|
||||
},
|
||||
clear() {
|
||||
texts = [];
|
||||
nextId = 0;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return texts.map(({ text }) => text);
|
||||
},
|
||||
set [STATE_DATA](newTexts: Array<string>) {
|
||||
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
||||
nextId = newTexts.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textsState = withPersistence("texts", createState());
|
||||
|
||||
+28
-28
@@ -1,28 +1,28 @@
|
||||
import { Theme, type ThemeType } from "$lib/constants";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
function createState() {
|
||||
let current: ThemeType = $state(Theme.LIGHT);
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
set current(value: ThemeType) {
|
||||
current = value;
|
||||
},
|
||||
toggle() {
|
||||
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
current,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](data: { current: ThemeType }) {
|
||||
current = data.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themeState = withPersistence("theme", createState());
|
||||
import { Theme, type ThemeType } from "$lib/constants";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
function createState() {
|
||||
let current: ThemeType = $state(Theme.LIGHT);
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
set current(value: ThemeType) {
|
||||
current = value;
|
||||
},
|
||||
toggle() {
|
||||
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
current,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](data: { current: ThemeType }) {
|
||||
current = data.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themeState = withPersistence("theme", createState());
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "vitest-canvas-mock";
|
||||
import "web-animations-js";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "vitest-canvas-mock";
|
||||
import "web-animations-js";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import CropInline from "$components/image/CropInline.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("CropInline.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(CropInline);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import CropInline from "$components/image/CropInline.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("CropInline.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(CropInline);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("ImageManager.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(ImageManager);
|
||||
});
|
||||
});
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("ImageManager.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(ImageManager);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("AppHeader.svelte", () => {
|
||||
beforeEach(() => {
|
||||
themeState.current = "dark";
|
||||
});
|
||||
|
||||
it("should toggle theme on button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(AppHeader);
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
|
||||
|
||||
themeState.current = "dark";
|
||||
await user.click(toggleButton);
|
||||
expect(themeState.current).toBe("light");
|
||||
|
||||
await user.click(toggleButton);
|
||||
expect(themeState.current).toBe("dark");
|
||||
});
|
||||
});
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("AppHeader.svelte", () => {
|
||||
beforeEach(() => {
|
||||
themeState.current = "dark";
|
||||
});
|
||||
|
||||
it("should toggle theme on button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(AppHeader);
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
|
||||
|
||||
themeState.current = "dark";
|
||||
await user.click(toggleButton);
|
||||
expect(themeState.current).toBe("light");
|
||||
|
||||
await user.click(toggleButton);
|
||||
expect(themeState.current).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import CardTest from "./CardTest.svelte";
|
||||
|
||||
describe("Card.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(CardTest);
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("card-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import CardTest from "./CardTest.svelte";
|
||||
|
||||
describe("Card.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(CardTest);
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("card-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
</script>
|
||||
|
||||
<Card title="Test Title">
|
||||
<div data-testid="card-content">Test content</div>
|
||||
</Card>
|
||||
<script>
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
</script>
|
||||
|
||||
<Card title="Test Title">
|
||||
<div data-testid="card-content">Test content</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import InputGroupTest from "./InputGroupTest.svelte";
|
||||
|
||||
describe("InputGroup.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(InputGroupTest);
|
||||
expect(screen.getByTestId("input1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("input2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import InputGroupTest from "./InputGroupTest.svelte";
|
||||
|
||||
describe("InputGroup.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(InputGroupTest);
|
||||
expect(screen.getByTestId("input1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("input2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script>
|
||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||
</script>
|
||||
|
||||
<InputGroup>
|
||||
<input type="text" data-testid="input1" />
|
||||
<input type="text" data-testid="input2" />
|
||||
<button data-testid="button">Кнопка</button>
|
||||
</InputGroup>
|
||||
<script>
|
||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||
</script>
|
||||
|
||||
<InputGroup>
|
||||
<input type="text" data-testid="input1" />
|
||||
<input type="text" data-testid="input2" />
|
||||
<button data-testid="button">Кнопка</button>
|
||||
</InputGroup>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("PanelBar.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(PanelBar);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("PanelBar.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(PanelBar);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import SettingsGridTest from "./SettingsGridTest.svelte";
|
||||
|
||||
describe("SettingsGrid.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(SettingsGridTest);
|
||||
expect(screen.getByTestId("item1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("item2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("item3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import SettingsGridTest from "./SettingsGridTest.svelte";
|
||||
|
||||
describe("SettingsGrid.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(SettingsGridTest);
|
||||
expect(screen.getByTestId("item1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("item2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("item3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script>
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
</script>
|
||||
|
||||
<SettingsGrid>
|
||||
<div data-testid="item1">Item 1</div>
|
||||
<div data-testid="item2">Item 2</div>
|
||||
<div data-testid="item3">Item 3</div>
|
||||
</SettingsGrid>
|
||||
<script>
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
</script>
|
||||
|
||||
<SettingsGrid>
|
||||
<div data-testid="item1">Item 1</div>
|
||||
<div data-testid="item2">Item 2</div>
|
||||
<div data-testid="item3">Item 3</div>
|
||||
</SettingsGrid>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import SettingsRowTest from "./SettingsRowTest.svelte";
|
||||
|
||||
describe("SettingsRow.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(SettingsRowTest);
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-input")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import SettingsRowTest from "./SettingsRowTest.svelte";
|
||||
|
||||
describe("SettingsRow.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(SettingsRowTest);
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-input")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
</script>
|
||||
|
||||
<SettingsRow label="Test Label">
|
||||
<input type="text" data-testid="test-input" />
|
||||
</SettingsRow>
|
||||
<script>
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
</script>
|
||||
|
||||
<SettingsRow label="Test Label">
|
||||
<input type="text" data-testid="test-input" />
|
||||
</SettingsRow>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import TextBar from "$components/layout/TextBar.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("TextBar.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(TextBar);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import TextBar from "$components/layout/TextBar.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("TextBar.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(TextBar);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import Preview from "$components/panel/Preview.svelte";
|
||||
import { cleanup, render } from "@testing-library/svelte";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Preview.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(Preview, {
|
||||
props: {
|
||||
text: "Test text",
|
||||
stage: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have stage element", () => {
|
||||
const { container } = render(Preview, {
|
||||
props: {
|
||||
text: "Test",
|
||||
stage: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const stage = container.querySelector("canvas");
|
||||
expect(stage).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import Preview from "$components/panel/Preview.svelte";
|
||||
import { cleanup, render } from "@testing-library/svelte";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Preview.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(Preview, {
|
||||
props: {
|
||||
text: "Test text",
|
||||
stage: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have stage element", () => {
|
||||
const { container } = render(Preview, {
|
||||
props: {
|
||||
text: "Test",
|
||||
stage: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const stage = container.querySelector("canvas");
|
||||
expect(stage).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import PreviewAll from "$components/panel/PreviewAll.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, it } from "vitest";
|
||||
|
||||
describe("PreviewAll.svelte", () => {
|
||||
beforeEach(() => {
|
||||
textsState.texts.length = 0;
|
||||
});
|
||||
|
||||
it("should render without crashing", () => {
|
||||
render(PreviewAll);
|
||||
});
|
||||
});
|
||||
import PreviewAll from "$components/panel/PreviewAll.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, it } from "vitest";
|
||||
|
||||
describe("PreviewAll.svelte", () => {
|
||||
beforeEach(() => {
|
||||
textsState.texts.length = 0;
|
||||
});
|
||||
|
||||
it("should render without crashing", () => {
|
||||
render(PreviewAll);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import { SlideDirection } from "$lib/constants";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import PreviewControlsTest from "./PreviewControlsTest.svelte";
|
||||
|
||||
describe("PreviewControls logic", () => {
|
||||
it("should increment current and update direction on next click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(PreviewControlsTest, { props: { current: 0, max: 3 } });
|
||||
|
||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||
|
||||
await user.click(nextBtn);
|
||||
|
||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||
expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT);
|
||||
});
|
||||
|
||||
it("should decrement current on prev click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(PreviewControlsTest, { props: { current: 2, max: 3 } });
|
||||
|
||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||
|
||||
await user.click(prevBtn);
|
||||
|
||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("should handle boundaries and disable buttons", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(PreviewControlsTest, { props: { current: 0, max: 2 } });
|
||||
|
||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||
|
||||
expect(prevBtn).toBeDisabled();
|
||||
|
||||
await user.click(nextBtn);
|
||||
|
||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||
expect(nextBtn).toBeDisabled();
|
||||
expect(prevBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
import { SlideDirection } from "$lib/constants";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import PreviewControlsTest from "./PreviewControlsTest.svelte";
|
||||
|
||||
describe("PreviewControls logic", () => {
|
||||
it("should increment current and update direction on next click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(PreviewControlsTest, { props: { current: 0, max: 3 } });
|
||||
|
||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||
|
||||
await user.click(nextBtn);
|
||||
|
||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||
expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT);
|
||||
});
|
||||
|
||||
it("should decrement current on prev click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(PreviewControlsTest, { props: { current: 2, max: 3 } });
|
||||
|
||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||
|
||||
await user.click(prevBtn);
|
||||
|
||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("should handle boundaries and disable buttons", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(PreviewControlsTest, { props: { current: 0, max: 2 } });
|
||||
|
||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||
|
||||
expect(prevBtn).toBeDisabled();
|
||||
|
||||
await user.click(nextBtn);
|
||||
|
||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||
expect(nextBtn).toBeDisabled();
|
||||
expect(prevBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import PreviewControls from "$components/panel/PreviewControls.svelte";
|
||||
|
||||
import { SlideDirection } from "$lib/constants";
|
||||
|
||||
let { current = 0, max = 5 } = $props();
|
||||
let direction = $state(SlideDirection.NEXT);
|
||||
</script>
|
||||
|
||||
<PreviewControls bind:current bind:direction {max} />
|
||||
|
||||
<div data-testid="current">{current}</div>
|
||||
<div data-testid="direction">{direction}</div>
|
||||
<script lang="ts">
|
||||
import PreviewControls from "$components/panel/PreviewControls.svelte";
|
||||
|
||||
import { SlideDirection } from "$lib/constants";
|
||||
|
||||
let { current = 0, max = 5 } = $props();
|
||||
let direction = $state(SlideDirection.NEXT);
|
||||
</script>
|
||||
|
||||
<PreviewControls bind:current bind:direction {max} />
|
||||
|
||||
<div data-testid="current">{current}</div>
|
||||
<div data-testid="direction">{direction}</div>
|
||||
|
||||
@@ -1,102 +1,103 @@
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
import { downloadService } from "$services/downloadService";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { render, screen, waitFor } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("$services/downloadService", () => ({
|
||||
downloadService: {
|
||||
downloadAll: vi.fn(),
|
||||
downloadPanel: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./Preview.svelte", () => ({
|
||||
default: { render: () => ({}) },
|
||||
}));
|
||||
describe("PreviewManager Integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
textsState[STATE_DATA] = [];
|
||||
});
|
||||
|
||||
it("should show empty state by aria-label when no texts", () => {
|
||||
render(PreviewManager);
|
||||
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle navigation buttons availability based on texts length", async () => {
|
||||
textsState[STATE_DATA] = ["1", "2"];
|
||||
render(PreviewManager);
|
||||
|
||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||
|
||||
expect(nextBtn).not.toBeDisabled();
|
||||
expect(prevBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should send correct data to downloadAll service", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState[STATE_DATA] = ["Apple", "Banana"];
|
||||
render(PreviewManager);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /download all/i }));
|
||||
|
||||
expect(downloadService.downloadAll).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ filename: "Apple" }),
|
||||
expect.objectContaining({ filename: "Banana" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call downloadPanel with current active text", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState[STATE_DATA] = ["First", "Second"];
|
||||
konvaStageState.stage = { node: { id: "stage-ref" } } as any;
|
||||
|
||||
render(PreviewManager);
|
||||
|
||||
// Переходим на второй слайд
|
||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
||||
await user.click(screen.getByRole("button", { name: /download current/i }));
|
||||
|
||||
expect(downloadService.downloadPanel).toHaveBeenCalledWith(expect.anything(), "Second");
|
||||
});
|
||||
|
||||
it("should return to empty state when texts are removed", async () => {
|
||||
textsState[STATE_DATA] = ["Temp"];
|
||||
render(PreviewManager);
|
||||
|
||||
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
|
||||
|
||||
textsState[STATE_DATA] = [];
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should automatically correct current index on items deletion", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState[STATE_DATA] = ["1", "2", "3"];
|
||||
render(PreviewManager);
|
||||
|
||||
// Уходим на последний слайд
|
||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
||||
|
||||
// Удаляем элементы. Индекс должен упасть с 2 до 0
|
||||
textsState[STATE_DATA] = ["Only one left"];
|
||||
|
||||
await waitFor(() => {
|
||||
// Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном)
|
||||
expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
import { downloadService } from "$services/downloadService";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { render, screen, waitFor } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Stage } from "svelte-konva";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("$services/downloadService", () => ({
|
||||
downloadService: {
|
||||
downloadAll: vi.fn(),
|
||||
downloadPanel: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./Preview.svelte", () => ({
|
||||
default: { render: () => ({}) },
|
||||
}));
|
||||
describe("PreviewManager Integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
textsState[STATE_DATA] = [];
|
||||
});
|
||||
|
||||
it("should show empty state by aria-label when no texts", () => {
|
||||
render(PreviewManager);
|
||||
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle navigation buttons availability based on texts length", async () => {
|
||||
textsState[STATE_DATA] = ["1", "2"];
|
||||
render(PreviewManager);
|
||||
|
||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||
|
||||
expect(nextBtn).not.toBeDisabled();
|
||||
expect(prevBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should send correct data to downloadAll service", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState[STATE_DATA] = ["Apple", "Banana"];
|
||||
render(PreviewManager);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /download all/i }));
|
||||
|
||||
expect(downloadService.downloadAll).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ filename: "Apple" }),
|
||||
expect.objectContaining({ filename: "Banana" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call downloadPanel with current active text", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState[STATE_DATA] = ["First", "Second"];
|
||||
konvaStageState.stage = { node: { id: "stage-ref" } } as unknown as Stage;
|
||||
|
||||
render(PreviewManager);
|
||||
|
||||
// Переходим на второй слайд
|
||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
||||
await user.click(screen.getByRole("button", { name: /download current/i }));
|
||||
|
||||
expect(downloadService.downloadPanel).toHaveBeenCalledWith(expect.anything(), "Second");
|
||||
});
|
||||
|
||||
it("should return to empty state when texts are removed", async () => {
|
||||
textsState[STATE_DATA] = ["Temp"];
|
||||
render(PreviewManager);
|
||||
|
||||
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
|
||||
|
||||
textsState[STATE_DATA] = [];
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should automatically correct current index on items deletion", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState[STATE_DATA] = ["1", "2", "3"];
|
||||
render(PreviewManager);
|
||||
|
||||
// Уходим на последний слайд
|
||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
||||
|
||||
// Удаляем элементы. Индекс должен упасть с 2 до 0
|
||||
textsState[STATE_DATA] = ["Only one left"];
|
||||
|
||||
await waitFor(() => {
|
||||
// Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном)
|
||||
expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import { cleanup, render } from "@testing-library/svelte";
|
||||
import { afterEach, describe, it } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TextConfig.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(TextConfig);
|
||||
});
|
||||
});
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import { cleanup, render } from "@testing-library/svelte";
|
||||
import { afterEach, describe, it } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TextConfig.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(TextConfig);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import TextInlineEdit from "$components/text/TextInlineEdit.svelte";
|
||||
import { cleanup, render, screen } from "@testing-library/svelte";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TextInlineEdit.svelte", () => {
|
||||
it("should render with initial text", () => {
|
||||
render(TextInlineEdit, {
|
||||
props: {
|
||||
id: 1,
|
||||
text: "Test text",
|
||||
ondelete: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Test text");
|
||||
});
|
||||
|
||||
it("should render with empty text", () => {
|
||||
render(TextInlineEdit, {
|
||||
props: {
|
||||
id: 1,
|
||||
text: "",
|
||||
ondelete: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
import TextInlineEdit from "$components/text/TextInlineEdit.svelte";
|
||||
import { cleanup, render, screen } from "@testing-library/svelte";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TextInlineEdit.svelte", () => {
|
||||
it("should render with initial text", () => {
|
||||
render(TextInlineEdit, {
|
||||
props: {
|
||||
id: 1,
|
||||
text: "Test text",
|
||||
ondelete: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Test text");
|
||||
});
|
||||
|
||||
it("should render with empty text", () => {
|
||||
render(TextInlineEdit, {
|
||||
props: {
|
||||
id: 1,
|
||||
text: "",
|
||||
ondelete: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import TextInput from "$components/text/TextInput.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("TextInput.svelte", () => {
|
||||
it("should render with initial value", () => {
|
||||
render(TextInput, {
|
||||
props: {
|
||||
text: "Test text",
|
||||
onenter: vi.fn(),
|
||||
ariaLabel: "Test input",
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Test text");
|
||||
});
|
||||
|
||||
it("should update value on input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(TextInput, {
|
||||
props: {
|
||||
text: "Initial",
|
||||
onenter: vi.fn(),
|
||||
ariaLabel: "Test input",
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||
await user.clear(input);
|
||||
await user.type(input, "Updated text");
|
||||
|
||||
expect(input).toHaveValue("Updated text");
|
||||
});
|
||||
|
||||
it("should call onenter only on Enter key and not on others", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onenterSpy = vi.fn();
|
||||
|
||||
render(TextInput, {
|
||||
props: {
|
||||
text: "test",
|
||||
onenter: onenterSpy,
|
||||
ariaLabel: "Test input",
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||
|
||||
await user.type(input, "abc");
|
||||
expect(onenterSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onenterSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(input, "{Enter}");
|
||||
expect(onenterSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
import TextInput from "$components/text/TextInput.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("TextInput.svelte", () => {
|
||||
it("should render with initial value", () => {
|
||||
render(TextInput, {
|
||||
props: {
|
||||
text: "Test text",
|
||||
onenter: vi.fn(),
|
||||
ariaLabel: "Test input",
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Test text");
|
||||
});
|
||||
|
||||
it("should update value on input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(TextInput, {
|
||||
props: {
|
||||
text: "Initial",
|
||||
onenter: vi.fn(),
|
||||
ariaLabel: "Test input",
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||
await user.clear(input);
|
||||
await user.type(input, "Updated text");
|
||||
|
||||
expect(input).toHaveValue("Updated text");
|
||||
});
|
||||
|
||||
it("should call onenter only on Enter key and not on others", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onenterSpy = vi.fn();
|
||||
|
||||
render(TextInput, {
|
||||
props: {
|
||||
text: "test",
|
||||
onenter: onenterSpy,
|
||||
ariaLabel: "Test input",
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||
|
||||
await user.type(input, "abc");
|
||||
expect(onenterSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onenterSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(input, "{Enter}");
|
||||
expect(onenterSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("TextManager", () => {
|
||||
beforeEach(() => {
|
||||
textsState.clear();
|
||||
});
|
||||
|
||||
it("should add text to state and clear input on button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(TextManager);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /input new text/i });
|
||||
const addButton = screen.getByRole("button", { name: /add text/i });
|
||||
|
||||
await user.type(input, "New Note");
|
||||
await user.click(addButton);
|
||||
|
||||
expect(textsState.texts).toHaveLength(1);
|
||||
expect(textsState.texts[0].text).toBe("New Note");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should add text on enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(TextManager);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /input new text/i });
|
||||
|
||||
await user.type(input, "Enter Note{Enter}");
|
||||
|
||||
expect(textsState.texts).toHaveLength(1);
|
||||
expect(textsState.texts[0].text).toBe("Enter Note");
|
||||
});
|
||||
|
||||
it("should display all items from state", async () => {
|
||||
textsState.addText("First");
|
||||
textsState.addText("Second");
|
||||
|
||||
render(TextManager);
|
||||
|
||||
const items = screen.getAllByRole("listitem");
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should remove text from state when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState.addText("To be deleted");
|
||||
|
||||
render(TextManager);
|
||||
|
||||
const deleteBtn = screen.getByRole("button", { name: /delete/i });
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(textsState.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("TextManager", () => {
|
||||
beforeEach(() => {
|
||||
textsState.clear();
|
||||
});
|
||||
|
||||
it("should add text to state and clear input on button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(TextManager);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /input new text/i });
|
||||
const addButton = screen.getByRole("button", { name: /add text/i });
|
||||
|
||||
await user.type(input, "New Note");
|
||||
await user.click(addButton);
|
||||
|
||||
expect(textsState.texts).toHaveLength(1);
|
||||
expect(textsState.texts[0].text).toBe("New Note");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should add text on enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(TextManager);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /input new text/i });
|
||||
|
||||
await user.type(input, "Enter Note{Enter}");
|
||||
|
||||
expect(textsState.texts).toHaveLength(1);
|
||||
expect(textsState.texts[0].text).toBe("Enter Note");
|
||||
});
|
||||
|
||||
it("should display all items from state", async () => {
|
||||
textsState.addText("First");
|
||||
textsState.addText("Second");
|
||||
|
||||
render(TextManager);
|
||||
|
||||
const items = screen.getAllByRole("listitem");
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should remove text from state when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
textsState.addText("To be deleted");
|
||||
|
||||
render(TextManager);
|
||||
|
||||
const deleteBtn = screen.getByRole("button", { name: /delete/i });
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(textsState.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
import { TextAlign } from "$lib/constants";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import AlignmentTest from "./AlignmentTest.svelte";
|
||||
|
||||
describe("Alignment.svelte", () => {
|
||||
it("should update state for every button by clicking in sequence", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(AlignmentTest);
|
||||
|
||||
const buttons = screen.getAllByRole("radio");
|
||||
const stateDisplay = screen.getByTestId("align-value");
|
||||
|
||||
const allButtonsToClick = [...buttons, buttons[0]];
|
||||
|
||||
for (const button of allButtonsToClick) {
|
||||
const label = button.getAttribute("aria-label")?.toLowerCase() || "";
|
||||
|
||||
await user.click(button);
|
||||
|
||||
const finalValue = stateDisplay.textContent?.toLowerCase() || "";
|
||||
expect(label).toContain(finalValue);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have initial state from props", () => {
|
||||
render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
||||
expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT);
|
||||
});
|
||||
|
||||
it("should sync with initial state and change state", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
||||
const stateDisplay = screen.getByTestId("align-value");
|
||||
|
||||
expect(stateDisplay.textContent).toBe(TextAlign.RIGHT);
|
||||
|
||||
await rerender({ align: TextAlign.CENTER });
|
||||
|
||||
const centerBtn = screen.getByRole("radio", { name: new RegExp(TextAlign.CENTER, "i") });
|
||||
expect(centerBtn).toBeChecked();
|
||||
});
|
||||
});
|
||||
import { TextAlign } from "$lib/constants";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import AlignmentTest from "./AlignmentTest.svelte";
|
||||
|
||||
describe("Alignment.svelte", () => {
|
||||
it("should update state for every button by clicking in sequence", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(AlignmentTest);
|
||||
|
||||
const buttons = screen.getAllByRole("radio");
|
||||
const stateDisplay = screen.getByTestId("align-value");
|
||||
|
||||
const allButtonsToClick = [...buttons, buttons[0]];
|
||||
|
||||
for (const button of allButtonsToClick) {
|
||||
const label = button.getAttribute("aria-label")?.toLowerCase() || "";
|
||||
|
||||
await user.click(button);
|
||||
|
||||
const finalValue = stateDisplay.textContent?.toLowerCase() || "";
|
||||
expect(label).toContain(finalValue);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have initial state from props", () => {
|
||||
render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
||||
expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT);
|
||||
});
|
||||
|
||||
it("should sync with initial state and change state", async () => {
|
||||
const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
||||
const stateDisplay = screen.getByTestId("align-value");
|
||||
|
||||
expect(stateDisplay.textContent).toBe(TextAlign.RIGHT);
|
||||
|
||||
await rerender({ align: TextAlign.CENTER });
|
||||
|
||||
const centerBtn = screen.getByRole("radio", { name: new RegExp(TextAlign.CENTER, "i") });
|
||||
expect(centerBtn).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
|
||||
import { TextAlign } from "$lib/constants";
|
||||
|
||||
let { align = TextAlign.LEFT } = $props();
|
||||
</script>
|
||||
|
||||
<Alignment bind:align />
|
||||
|
||||
<div data-testid="align-value">{align}</div>
|
||||
<script lang="ts">
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
|
||||
import { TextAlign } from "$lib/constants";
|
||||
|
||||
let { align = TextAlign.LEFT } = $props();
|
||||
</script>
|
||||
|
||||
<Alignment bind:align />
|
||||
|
||||
<div data-testid="align-value">{align}</div>
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Badge.svelte", () => {
|
||||
it("should render with string text", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: "Test Badge",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Test Badge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with number text", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: 42,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with empty string", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render with zero", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Badge.svelte", () => {
|
||||
it("should render with string text", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: "Test Badge",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Test Badge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with number text", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: 42,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with empty string", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render with zero", () => {
|
||||
render(Badge, {
|
||||
props: {
|
||||
text: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import MockIcon from "./MockIcon.svelte";
|
||||
|
||||
describe("Button.svelte", () => {
|
||||
it("should render with icon and label", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Test Button",
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Test Button")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with icon only", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.textContent.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should call onclick handler", async () => {
|
||||
const onclick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Click me",
|
||||
onclick,
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
await user.click(button);
|
||||
|
||||
expect(onclick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not throw error when clicked without onclick prop", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Click me",
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
|
||||
expect(() => user.click(button)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Disabled",
|
||||
disabled: true,
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not be disabled by default", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Enabled",
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import MockIcon from "./MockIcon.svelte";
|
||||
|
||||
describe("Button.svelte", () => {
|
||||
it("should render with icon and label", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Test Button",
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Test Button")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with icon only", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.textContent.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should call onclick handler", async () => {
|
||||
const onclick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Click me",
|
||||
onclick,
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
await user.click(button);
|
||||
|
||||
expect(onclick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not throw error when clicked without onclick prop", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Click me",
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
|
||||
expect(() => user.click(button)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Disabled",
|
||||
disabled: true,
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not be disabled by default", () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
icon: MockIcon,
|
||||
label: "Enabled",
|
||||
ariaLabel: "Test button",
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: /test button/i });
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("ColorPicker.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(ColorPicker, {
|
||||
props: {
|
||||
value: "#ffffff",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("ColorPicker.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(ColorPicker, {
|
||||
props: {
|
||||
value: "#ffffff",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg data-testid="mock-icon" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<svg data-testid="mock-icon" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 96 B After Width: | Height: | Size: 93 B |
@@ -1,21 +1,21 @@
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import { fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("RangeSlider.svelte", () => {
|
||||
it("should call onchange handler", async () => {
|
||||
const onchange = vi.fn();
|
||||
|
||||
render(RangeSlider, {
|
||||
props: {
|
||||
value: 50,
|
||||
onchange,
|
||||
},
|
||||
});
|
||||
|
||||
const slider = screen.getByRole("slider");
|
||||
await fireEvent.change(slider, { target: { value: "75" } });
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import { fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("RangeSlider.svelte", () => {
|
||||
it("should call onchange handler", async () => {
|
||||
const onchange = vi.fn();
|
||||
|
||||
render(RangeSlider, {
|
||||
props: {
|
||||
value: 50,
|
||||
onchange,
|
||||
},
|
||||
});
|
||||
|
||||
const slider = screen.getByRole("slider");
|
||||
await fireEvent.change(slider, { target: { value: "75" } });
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("SelectFont.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(SelectFont, {
|
||||
props: {
|
||||
value: "Arial",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("SelectFont.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(SelectFont, {
|
||||
props: {
|
||||
value: "Arial",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +1,70 @@
|
||||
import * as Constants from "$lib/constants";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Application Constants Logic", () => {
|
||||
describe("Typography & Panel Constraints", () => {
|
||||
it("should have default values within allowed boundaries", () => {
|
||||
const { TYPOGRAPHY, PANEL_SETTINGS } = Constants;
|
||||
|
||||
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN);
|
||||
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX);
|
||||
|
||||
expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
|
||||
|
||||
expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(PANEL_SETTINGS.PANEL_HEIGHT_MAX);
|
||||
|
||||
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN);
|
||||
});
|
||||
|
||||
it("should not have duplicate values in lists", () => {
|
||||
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
||||
|
||||
const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES);
|
||||
expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length);
|
||||
|
||||
const uniqueFormats = new Set(IMAGE_SETTINGS.SUPPORTED_FORMATS);
|
||||
expect(uniqueFormats.size).toBe(IMAGE_SETTINGS.SUPPORTED_FORMATS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integrity & Formats", () => {
|
||||
it("should have valid format patterns for colors and mime-types", () => {
|
||||
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
||||
|
||||
expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
|
||||
IMAGE_SETTINGS.SUPPORTED_FORMATS.forEach((format) => {
|
||||
expect(format).toMatch(/^image\/(jpeg|jpg|png|webp|gif)$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Specifics", () => {
|
||||
it("should set transition duration to 0 in test mode", () => {
|
||||
expect(Constants.TRANSITION_DURATION).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Global Contract (Snapshot)", () => {
|
||||
it("should match the previous configuration snapshot", () => {
|
||||
expect(Constants).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Assets Existence", () => {
|
||||
it("should verify that the default background image exists", () => {
|
||||
const { DEFAULT_BACKGROUND_IMAGE } = Constants.PANEL_SETTINGS;
|
||||
|
||||
const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, "");
|
||||
const fullPath = path.resolve(process.cwd(), "static", relativePath);
|
||||
|
||||
const exists = fs.existsSync(fullPath);
|
||||
|
||||
expect(exists, `Image not found at: ${fullPath}`).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
import * as Constants from "$lib/constants";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Application Constants Logic", () => {
|
||||
describe("Typography & Panel Constraints", () => {
|
||||
it("should have default values within allowed boundaries", () => {
|
||||
const { TYPOGRAPHY, PANEL_SETTINGS } = Constants;
|
||||
|
||||
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN);
|
||||
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX);
|
||||
|
||||
expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
|
||||
|
||||
expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(
|
||||
PANEL_SETTINGS.PANEL_HEIGHT_MAX,
|
||||
);
|
||||
|
||||
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN);
|
||||
});
|
||||
|
||||
it("should not have duplicate values in lists", () => {
|
||||
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
||||
|
||||
const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES);
|
||||
expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length);
|
||||
|
||||
const uniqueFormats = new Set(IMAGE_SETTINGS.SUPPORTED_FORMATS);
|
||||
expect(uniqueFormats.size).toBe(IMAGE_SETTINGS.SUPPORTED_FORMATS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integrity & Formats", () => {
|
||||
it("should have valid format patterns for colors and mime-types", () => {
|
||||
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
||||
|
||||
expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
|
||||
IMAGE_SETTINGS.SUPPORTED_FORMATS.forEach((format) => {
|
||||
expect(format).toMatch(/^image\/(jpeg|jpg|png|webp|gif)$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Specifics", () => {
|
||||
it("should set transition duration to 0 in test mode", () => {
|
||||
expect(Constants.TRANSITION_DURATION).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Global Contract (Snapshot)", () => {
|
||||
it("should match the previous configuration snapshot", () => {
|
||||
expect(Constants).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Assets Existence", () => {
|
||||
it("should verify that the default background image exists", () => {
|
||||
const { DEFAULT_BACKGROUND_IMAGE } = Constants.PANEL_SETTINGS;
|
||||
|
||||
const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, "");
|
||||
const fullPath = path.resolve(process.cwd(), "static", relativePath);
|
||||
|
||||
const exists = fs.existsSync(fullPath);
|
||||
|
||||
expect(exists, `Image not found at: ${fullPath}`).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+207
-207
@@ -1,207 +1,207 @@
|
||||
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("error.types", () => {
|
||||
describe("AppError", () => {
|
||||
it("should create AppError with message and code", () => {
|
||||
const error = new AppError("Test error message", "TEST_CODE");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Test error message");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
expect(error.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create AppError with message, code and details", () => {
|
||||
const details = { userId: 123, action: "test" };
|
||||
const error = new AppError("Test error message", "TEST_CODE", details);
|
||||
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe("Test error message");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
});
|
||||
|
||||
it("should have stack trace", () => {
|
||||
const error = new AppError("Test error", "TEST_CODE");
|
||||
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe("string");
|
||||
});
|
||||
|
||||
it("should be throwable and catchable", () => {
|
||||
expect(() => {
|
||||
throw new AppError("Test error", "TEST_CODE");
|
||||
}).toThrow(AppError);
|
||||
});
|
||||
|
||||
it("should be catchable as Error", () => {
|
||||
expect(() => {
|
||||
throw new AppError("Test error", "TEST_CODE");
|
||||
}).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImageError", () => {
|
||||
it("should create ImageError with message", () => {
|
||||
const error = new ImageError("Image loading failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(ImageError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Image loading failed");
|
||||
expect(error.code).toBe("IMAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new ImageError("Test");
|
||||
|
||||
expect(error.code).toBe("IMAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as ImageError", () => {
|
||||
const error = new ImageError("Test");
|
||||
|
||||
expect(error instanceof ImageError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TextError", () => {
|
||||
it("should create TextError with message", () => {
|
||||
const error = new TextError("Text validation failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(TextError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Text validation failed");
|
||||
expect(error.code).toBe("TEXT_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new TextError("Test");
|
||||
|
||||
expect(error.code).toBe("TEXT_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as TextError", () => {
|
||||
const error = new TextError("Test");
|
||||
|
||||
expect(error instanceof TextError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CanvasError", () => {
|
||||
it("should create CanvasError with message", () => {
|
||||
const error = new CanvasError("Canvas rendering failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(CanvasError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Canvas rendering failed");
|
||||
expect(error.code).toBe("CANVAS_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new CanvasError("Test");
|
||||
|
||||
expect(error.code).toBe("CANVAS_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as CanvasError", () => {
|
||||
const error = new CanvasError("Test");
|
||||
|
||||
expect(error instanceof CanvasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageError", () => {
|
||||
it("should create StorageError with message", () => {
|
||||
const error = new StorageError("Storage operation failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(StorageError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Storage operation failed");
|
||||
expect(error.code).toBe("STORAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new StorageError("Test");
|
||||
|
||||
expect(error.code).toBe("STORAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as StorageError", () => {
|
||||
const error = new StorageError("Test");
|
||||
|
||||
expect(error instanceof StorageError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorType union", () => {
|
||||
it("should accept all error types", () => {
|
||||
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
||||
new AppError("Test", "TEST"),
|
||||
new ImageError("Test"),
|
||||
new TextError("Test"),
|
||||
new CanvasError("Test"),
|
||||
new StorageError("Test"),
|
||||
];
|
||||
|
||||
errors.forEach((error) => {
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow type narrowing with instanceof", () => {
|
||||
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
||||
new ImageError("Test"),
|
||||
new TextError("Test"),
|
||||
];
|
||||
|
||||
const imageErrors = errors.filter((e) => e instanceof ImageError);
|
||||
const textErrors = errors.filter((e) => e instanceof TextError);
|
||||
|
||||
expect(imageErrors).toHaveLength(1);
|
||||
expect(textErrors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling patterns", () => {
|
||||
it("should handle errors in try-catch blocks", () => {
|
||||
let caughtError: AppError | null = null;
|
||||
|
||||
try {
|
||||
throw new ImageError("Image failed to load");
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
caughtError = error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(caughtError).not.toBeNull();
|
||||
expect(caughtError?.code).toBe("IMAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should preserve error details through error handling", () => {
|
||||
const originalError = new AppError("Test", "TEST", { id: 123 });
|
||||
let caughtError: AppError | null = null;
|
||||
|
||||
try {
|
||||
throw originalError;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
caughtError = error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(caughtError?.details).toEqual({ id: 123 });
|
||||
});
|
||||
});
|
||||
});
|
||||
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("error.types", () => {
|
||||
describe("AppError", () => {
|
||||
it("should create AppError with message and code", () => {
|
||||
const error = new AppError("Test error message", "TEST_CODE");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Test error message");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
expect(error.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create AppError with message, code and details", () => {
|
||||
const details = { userId: 123, action: "test" };
|
||||
const error = new AppError("Test error message", "TEST_CODE", details);
|
||||
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe("Test error message");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
});
|
||||
|
||||
it("should have stack trace", () => {
|
||||
const error = new AppError("Test error", "TEST_CODE");
|
||||
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe("string");
|
||||
});
|
||||
|
||||
it("should be throwable and catchable", () => {
|
||||
expect(() => {
|
||||
throw new AppError("Test error", "TEST_CODE");
|
||||
}).toThrow(AppError);
|
||||
});
|
||||
|
||||
it("should be catchable as Error", () => {
|
||||
expect(() => {
|
||||
throw new AppError("Test error", "TEST_CODE");
|
||||
}).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImageError", () => {
|
||||
it("should create ImageError with message", () => {
|
||||
const error = new ImageError("Image loading failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(ImageError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Image loading failed");
|
||||
expect(error.code).toBe("IMAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new ImageError("Test");
|
||||
|
||||
expect(error.code).toBe("IMAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as ImageError", () => {
|
||||
const error = new ImageError("Test");
|
||||
|
||||
expect(error instanceof ImageError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TextError", () => {
|
||||
it("should create TextError with message", () => {
|
||||
const error = new TextError("Text validation failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(TextError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Text validation failed");
|
||||
expect(error.code).toBe("TEXT_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new TextError("Test");
|
||||
|
||||
expect(error.code).toBe("TEXT_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as TextError", () => {
|
||||
const error = new TextError("Test");
|
||||
|
||||
expect(error instanceof TextError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CanvasError", () => {
|
||||
it("should create CanvasError with message", () => {
|
||||
const error = new CanvasError("Canvas rendering failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(CanvasError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Canvas rendering failed");
|
||||
expect(error.code).toBe("CANVAS_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new CanvasError("Test");
|
||||
|
||||
expect(error.code).toBe("CANVAS_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as CanvasError", () => {
|
||||
const error = new CanvasError("Test");
|
||||
|
||||
expect(error instanceof CanvasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageError", () => {
|
||||
it("should create StorageError with message", () => {
|
||||
const error = new StorageError("Storage operation failed");
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(StorageError);
|
||||
expect(error.name).toBe("AppError");
|
||||
expect(error.message).toBe("Storage operation failed");
|
||||
expect(error.code).toBe("STORAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should have correct error code", () => {
|
||||
const error = new StorageError("Test");
|
||||
|
||||
expect(error.code).toBe("STORAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should be identifiable as StorageError", () => {
|
||||
const error = new StorageError("Test");
|
||||
|
||||
expect(error instanceof StorageError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorType union", () => {
|
||||
it("should accept all error types", () => {
|
||||
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
||||
new AppError("Test", "TEST"),
|
||||
new ImageError("Test"),
|
||||
new TextError("Test"),
|
||||
new CanvasError("Test"),
|
||||
new StorageError("Test"),
|
||||
];
|
||||
|
||||
errors.forEach((error) => {
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow type narrowing with instanceof", () => {
|
||||
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
||||
new ImageError("Test"),
|
||||
new TextError("Test"),
|
||||
];
|
||||
|
||||
const imageErrors = errors.filter((e) => e instanceof ImageError);
|
||||
const textErrors = errors.filter((e) => e instanceof TextError);
|
||||
|
||||
expect(imageErrors).toHaveLength(1);
|
||||
expect(textErrors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling patterns", () => {
|
||||
it("should handle errors in try-catch blocks", () => {
|
||||
let caughtError: AppError | null = null;
|
||||
|
||||
try {
|
||||
throw new ImageError("Image failed to load");
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
caughtError = error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(caughtError).not.toBeNull();
|
||||
expect(caughtError?.code).toBe("IMAGE_ERROR");
|
||||
});
|
||||
|
||||
it("should preserve error details through error handling", () => {
|
||||
const originalError = new AppError("Test", "TEST", { id: 123 });
|
||||
let caughtError: AppError | null = null;
|
||||
|
||||
try {
|
||||
throw originalError;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
caughtError = error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(caughtError?.details).toEqual({ id: 123 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+155
-155
@@ -1,155 +1,155 @@
|
||||
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
||||
import { createError, formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("errorUtils", () => {
|
||||
describe("formatError", () => {
|
||||
it("should format AppError", () => {
|
||||
const error = new AppError("Test error", "TEST_CODE");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Test error");
|
||||
});
|
||||
|
||||
it("should format Error", () => {
|
||||
const error = new Error("Test error");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Test error");
|
||||
});
|
||||
|
||||
it("should format string error", () => {
|
||||
const error = "String error message";
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: String error message");
|
||||
});
|
||||
|
||||
it("should format unknown error", () => {
|
||||
const error = { custom: "object" };
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Произошла неизвестная ошибка");
|
||||
});
|
||||
|
||||
it("should use default message when not provided", () => {
|
||||
const error = new Error("Test error");
|
||||
const result = formatError(error);
|
||||
|
||||
expect(result).toBe("Произошла ошибка: Test error");
|
||||
});
|
||||
|
||||
it("should format ImageError", () => {
|
||||
const error = new ImageError("Image failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Image failed");
|
||||
});
|
||||
|
||||
it("should format TextError", () => {
|
||||
const error = new TextError("Text failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Text failed");
|
||||
});
|
||||
|
||||
it("should format CanvasError", () => {
|
||||
const error = new CanvasError("Canvas failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Canvas failed");
|
||||
});
|
||||
|
||||
it("should format StorageError", () => {
|
||||
const error = new StorageError("Storage failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Storage failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createError", () => {
|
||||
it("should create AppError with message and code", () => {
|
||||
const error = createError("Test error", "TEST_CODE");
|
||||
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error.message).toBe("Test error");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
expect(error.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create AppError with message, code and details", () => {
|
||||
const details = { userId: 123, action: "test" };
|
||||
const error = createError("Test error", "TEST_CODE", details);
|
||||
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe("Test error");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logError", () => {
|
||||
it("should log Error with stack", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new Error("Test error");
|
||||
|
||||
logError(error, "Test context");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error,
|
||||
context: "Test context",
|
||||
timestamp: expect.any(String),
|
||||
stack: expect.any(String),
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log AppError with stack", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new AppError("Test error", "TEST_CODE");
|
||||
|
||||
logError(error, "Test context");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error,
|
||||
context: "Test context",
|
||||
timestamp: expect.any(String),
|
||||
stack: expect.any(String),
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log string error without stack", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
logError("String error", "Test context");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error: "String error",
|
||||
context: "Test context",
|
||||
timestamp: expect.any(String),
|
||||
stack: undefined,
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log error without context", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new Error("Test error");
|
||||
|
||||
logError(error);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error,
|
||||
context: undefined,
|
||||
timestamp: expect.any(String),
|
||||
stack: expect.any(String),
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
||||
import { createError, formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("errorUtils", () => {
|
||||
describe("formatError", () => {
|
||||
it("should format AppError", () => {
|
||||
const error = new AppError("Test error", "TEST_CODE");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Test error");
|
||||
});
|
||||
|
||||
it("should format Error", () => {
|
||||
const error = new Error("Test error");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Test error");
|
||||
});
|
||||
|
||||
it("should format string error", () => {
|
||||
const error = "String error message";
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: String error message");
|
||||
});
|
||||
|
||||
it("should format unknown error", () => {
|
||||
const error = { custom: "object" };
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Произошла неизвестная ошибка");
|
||||
});
|
||||
|
||||
it("should use default message when not provided", () => {
|
||||
const error = new Error("Test error");
|
||||
const result = formatError(error);
|
||||
|
||||
expect(result).toBe("Произошла ошибка: Test error");
|
||||
});
|
||||
|
||||
it("should format ImageError", () => {
|
||||
const error = new ImageError("Image failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Image failed");
|
||||
});
|
||||
|
||||
it("should format TextError", () => {
|
||||
const error = new TextError("Text failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Text failed");
|
||||
});
|
||||
|
||||
it("should format CanvasError", () => {
|
||||
const error = new CanvasError("Canvas failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Canvas failed");
|
||||
});
|
||||
|
||||
it("should format StorageError", () => {
|
||||
const error = new StorageError("Storage failed");
|
||||
const result = formatError(error, "Default message");
|
||||
|
||||
expect(result).toBe("Default message: Storage failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createError", () => {
|
||||
it("should create AppError with message and code", () => {
|
||||
const error = createError("Test error", "TEST_CODE");
|
||||
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect(error.message).toBe("Test error");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
expect(error.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create AppError with message, code and details", () => {
|
||||
const details = { userId: 123, action: "test" };
|
||||
const error = createError("Test error", "TEST_CODE", details);
|
||||
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe("Test error");
|
||||
expect(error.code).toBe("TEST_CODE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logError", () => {
|
||||
it("should log Error with stack", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new Error("Test error");
|
||||
|
||||
logError(error, "Test context");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error,
|
||||
context: "Test context",
|
||||
timestamp: expect.any(String),
|
||||
stack: expect.any(String),
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log AppError with stack", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new AppError("Test error", "TEST_CODE");
|
||||
|
||||
logError(error, "Test context");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error,
|
||||
context: "Test context",
|
||||
timestamp: expect.any(String),
|
||||
stack: expect.any(String),
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log string error without stack", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
logError("String error", "Test context");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error: "String error",
|
||||
context: "Test context",
|
||||
timestamp: expect.any(String),
|
||||
stack: undefined,
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log error without context", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new Error("Test error");
|
||||
|
||||
logError(error);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||
error,
|
||||
context: undefined,
|
||||
timestamp: expect.any(String),
|
||||
stack: expect.any(String),
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import Layout from "$routes/+layout.svelte";
|
||||
</script>
|
||||
|
||||
<Layout>
|
||||
<span data-testid="test-child">Hello</span>
|
||||
</Layout>
|
||||
<script>
|
||||
import Layout from "$routes/+layout.svelte";
|
||||
</script>
|
||||
|
||||
<Layout>
|
||||
<span data-testid="test-child">Hello</span>
|
||||
</Layout>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Page from "$routes/+page.svelte";
|
||||
</script>
|
||||
|
||||
<Page />
|
||||
<script>
|
||||
import Page from "$routes/+page.svelte";
|
||||
</script>
|
||||
|
||||
<Page />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { prerender, ssr } from "$routes/+layout";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("+layout.ts", () => {
|
||||
it("should export ssr as false", () => {
|
||||
expect(ssr).toBe(false);
|
||||
});
|
||||
|
||||
it("should export prerender as true", () => {
|
||||
expect(prerender).toBe(true);
|
||||
});
|
||||
});
|
||||
import { prerender, ssr } from "$routes/+layout";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("+layout.ts", () => {
|
||||
it("should export ssr as false", () => {
|
||||
expect(ssr).toBe(false);
|
||||
});
|
||||
|
||||
it("should export prerender as true", () => {
|
||||
expect(prerender).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LayoutTest from "./LayoutTest.svelte";
|
||||
|
||||
describe("+layout.svelte", () => {
|
||||
beforeEach(() => {
|
||||
themeState.current = "dark";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should apply dark theme", () => {
|
||||
themeState.current = "dark";
|
||||
|
||||
expect(themeState.current).toBe("dark");
|
||||
});
|
||||
|
||||
it("should apply light theme", () => {
|
||||
themeState.current = "light";
|
||||
|
||||
expect(themeState.current).toBe("light");
|
||||
});
|
||||
|
||||
it("should toggle theme", () => {
|
||||
themeState.current = "dark";
|
||||
themeState.toggle();
|
||||
|
||||
expect(themeState.current).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout Component Coverage", () => {
|
||||
it("should render children snippet and initialize props", () => {
|
||||
render(LayoutTest);
|
||||
|
||||
expect(screen.getByTestId("test-child")).toBeInTheDocument();
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LayoutTest from "./LayoutTest.svelte";
|
||||
|
||||
describe("+layout.svelte", () => {
|
||||
beforeEach(() => {
|
||||
themeState.current = "dark";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should apply dark theme", () => {
|
||||
themeState.current = "dark";
|
||||
|
||||
expect(themeState.current).toBe("dark");
|
||||
});
|
||||
|
||||
it("should apply light theme", () => {
|
||||
themeState.current = "light";
|
||||
|
||||
expect(themeState.current).toBe("light");
|
||||
});
|
||||
|
||||
it("should toggle theme", () => {
|
||||
themeState.current = "dark";
|
||||
themeState.toggle();
|
||||
|
||||
expect(themeState.current).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout Component Coverage", () => {
|
||||
it("should render children snippet and initialize props", () => {
|
||||
render(LayoutTest);
|
||||
|
||||
expect(screen.getByTestId("test-child")).toBeInTheDocument();
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import PageTest from "./PageTest.svelte";
|
||||
|
||||
describe("+page.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(PageTest);
|
||||
expect(screen.getByText("Тексты панелей")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import PageTest from "./PageTest.svelte";
|
||||
|
||||
describe("+page.svelte", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(PageTest);
|
||||
expect(screen.getByText("Тексты панелей")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,125 +1,131 @@
|
||||
import { DownloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("file-saver", () => {
|
||||
return {
|
||||
saveAs: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("jszip", () => {
|
||||
const mockZipInstance = {
|
||||
file: vi.fn().mockReturnThis(),
|
||||
generateAsync: vi.fn().mockResolvedValue(new Blob([])),
|
||||
};
|
||||
|
||||
return {
|
||||
default: vi.fn(function () {
|
||||
return mockZipInstance;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("DownloadService", () => {
|
||||
let service: DownloadService;
|
||||
let mockKonvaStage: any;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DownloadService();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockKonvaStage = {
|
||||
toBlob: vi.fn(({ callback }) => {
|
||||
callback(new Blob(["test image data"], { type: "image/png" }));
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("downloadPanel", () => {
|
||||
it("should export panel successfully", async () => {
|
||||
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1");
|
||||
|
||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
||||
expect(blobArg instanceof Blob).toBe(true);
|
||||
expect(fileNameArg).toBe("test-panel-1.png");
|
||||
});
|
||||
|
||||
it("should handle Konva stage without toBlob method", async () => {
|
||||
const invalidStage = { toBlob: undefined };
|
||||
|
||||
const result = await service.downloadPanel(invalidStage as any, "test-panel-1.png");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle blob creation failure", async () => {
|
||||
mockKonvaStage.toBlob = vi.fn(({ callback }) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1.png");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Не удалось создать изображение");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadAllPanels", () => {
|
||||
it("should handle successful download of multiple panels", async () => {
|
||||
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: mockKonvaStage }];
|
||||
const result = await service.downloadAll(panels);
|
||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
||||
expect(blobArg instanceof Blob).toBe(true);
|
||||
expect(fileNameArg).toBe("panels.zip");
|
||||
expect(zipInstance.file).toHaveBeenCalledTimes(1);
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
||||
});
|
||||
|
||||
it("should add to zip all files", async () => {
|
||||
const panels: Array<DownloadItem> = [
|
||||
{ filename: "test-panel-1", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-2", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-3", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-4", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-5", stage: mockKonvaStage },
|
||||
];
|
||||
const result = await service.downloadAll(panels);
|
||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockKonvaStage.toBlob).toHaveBeenCalledTimes(5);
|
||||
expect(zipInstance.file).toHaveBeenCalledTimes(5);
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-2.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-3.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-4.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-5.png", expect.anything());
|
||||
});
|
||||
|
||||
it("should handle failure", async () => {
|
||||
const invalidStage = { toBlob: undefined };
|
||||
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: invalidStage as any }];
|
||||
const result = await service.downloadAll(panels);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Ошибка сохранения архива");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
import { DownloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import type { Stage } from "konva/lib/Stage";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("file-saver", () => {
|
||||
return {
|
||||
saveAs: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("jszip", () => {
|
||||
const mockZipInstance = {
|
||||
file: vi.fn().mockReturnThis(),
|
||||
generateAsync: vi.fn().mockResolvedValue(new Blob([])),
|
||||
};
|
||||
|
||||
return {
|
||||
default: vi.fn(function () {
|
||||
return mockZipInstance;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("DownloadService", () => {
|
||||
let service: DownloadService;
|
||||
let mockKonvaStage: Stage;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DownloadService();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockKonvaStage = {
|
||||
toBlob: vi.fn(async ({ callback }) => {
|
||||
callback(new Blob(["test image data"], { type: "image/png" }));
|
||||
}),
|
||||
} as unknown as Stage;
|
||||
});
|
||||
|
||||
describe("downloadPanel", () => {
|
||||
it("should export panel successfully", async () => {
|
||||
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1");
|
||||
|
||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
||||
expect(blobArg instanceof Blob).toBe(true);
|
||||
expect(fileNameArg).toBe("test-panel-1.png");
|
||||
});
|
||||
|
||||
it("should handle Konva stage without toBlob method", async () => {
|
||||
const invalidStage = { toBlob: undefined };
|
||||
|
||||
const result = await service.downloadPanel(
|
||||
invalidStage as unknown as Stage,
|
||||
"test-panel-1.png",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle blob creation failure", async () => {
|
||||
mockKonvaStage.toBlob = vi.fn(async ({ callback }) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1.png");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Не удалось создать изображение");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadAllPanels", () => {
|
||||
it("should handle successful download of multiple panels", async () => {
|
||||
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: mockKonvaStage }];
|
||||
const result = await service.downloadAll(panels);
|
||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
||||
expect(blobArg instanceof Blob).toBe(true);
|
||||
expect(fileNameArg).toBe("panels.zip");
|
||||
expect(zipInstance.file).toHaveBeenCalledTimes(1);
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
||||
});
|
||||
|
||||
it("should add to zip all files", async () => {
|
||||
const panels: Array<DownloadItem> = [
|
||||
{ filename: "test-panel-1", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-2", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-3", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-4", stage: mockKonvaStage },
|
||||
{ filename: "test-panel-5", stage: mockKonvaStage },
|
||||
];
|
||||
const result = await service.downloadAll(panels);
|
||||
// const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockKonvaStage.toBlob).toHaveBeenCalledTimes(5);
|
||||
expect(zipInstance.file).toHaveBeenCalledTimes(5);
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-2.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-3.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-4.png", expect.anything());
|
||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-5.png", expect.anything());
|
||||
});
|
||||
|
||||
it("should handle failure", async () => {
|
||||
const invalidStage = { toBlob: undefined };
|
||||
const panels: Array<DownloadItem> = [
|
||||
{ filename: "test-panel-1", stage: invalidStage as unknown as Stage },
|
||||
];
|
||||
const result = await service.downloadAll(panels);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Ошибка сохранения архива");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,192 +1,216 @@
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState, ImageConfigState } from "$states/imageConfig.svelte";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let lastOnload: (() => void) | null = null;
|
||||
let lastOnerror: (() => void) | null = null;
|
||||
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
_onload: (() => void) | null = null;
|
||||
_onerror: (() => void) | null = null;
|
||||
_src: string = "";
|
||||
crossOrigin: string = "";
|
||||
|
||||
set src(val: string) {
|
||||
this._src = val;
|
||||
if (val.includes("error")) {
|
||||
setTimeout(() => this._onerror?.(), 1);
|
||||
} else {
|
||||
setTimeout(() => this._onload?.(), 1);
|
||||
}
|
||||
}
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
|
||||
set onload(val: any) {
|
||||
this._onload = val;
|
||||
if (val) lastOnload = val;
|
||||
}
|
||||
get onload() {
|
||||
return this._onload;
|
||||
}
|
||||
|
||||
set onerror(val: any) {
|
||||
this._onerror = val;
|
||||
if (val) lastOnerror = val;
|
||||
}
|
||||
get onerror() {
|
||||
return this._onerror;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("ImageConfigState", () => {
|
||||
beforeEach(() => {
|
||||
lastOnload = null;
|
||||
lastOnerror = null;
|
||||
imageConfigState.reset();
|
||||
});
|
||||
|
||||
it("should create new instance with default values", () => {
|
||||
const newState = new ImageConfigState();
|
||||
expect(newState.image).toBeUndefined();
|
||||
expect(newState.imageLink).toBe("");
|
||||
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 imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(true);
|
||||
expect(imageConfigState.image).toBeDefined();
|
||||
expect(imageConfigState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
});
|
||||
|
||||
it("should handle manual image upload correctly", async () => {
|
||||
const testLink = "https://example.com/test.png";
|
||||
const uploadPromise = imageConfigState.uploadImageByLink(testLink);
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
|
||||
await uploadPromise;
|
||||
expect(imageConfigState.imageReady).toBe(true);
|
||||
expect(imageConfigState.imageLink).toBe(testLink);
|
||||
});
|
||||
|
||||
it("should reset state to defaults", async () => {
|
||||
await imageConfigState.uploadImageByLink("some-image.png");
|
||||
imageConfigState.cropLeft = 100;
|
||||
|
||||
imageConfigState.reset();
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
expect(imageConfigState.imageLink).toBe("");
|
||||
expect(imageConfigState.cropLeft).toBe(0);
|
||||
expect(imageConfigState.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle image loading error", async () => {
|
||||
await expect(imageConfigState.uploadImageByLink("error-link")).rejects.toThrow("Failed to load image");
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
});
|
||||
|
||||
it("should abort previous upload when new upload starts", async () => {
|
||||
const upload1 = imageConfigState.uploadImageByLink("test1.jpg");
|
||||
const upload2 = imageConfigState.uploadImageByLink("test2.jpg");
|
||||
|
||||
await expect(upload1).rejects.toThrow("Aborted");
|
||||
await expect(upload2).resolves.toBeUndefined();
|
||||
|
||||
expect(imageConfigState.imageLink).toBe("test2.jpg");
|
||||
});
|
||||
|
||||
it("should cleanup previous image before loading new one", async () => {
|
||||
await imageConfigState.uploadImageByLink("test1.jpg");
|
||||
const firstImage = imageConfigState.image;
|
||||
|
||||
await imageConfigState.uploadImageByLink("test2.jpg");
|
||||
|
||||
expect(firstImage?.onload).toBeNull();
|
||||
expect(firstImage?.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("should set crop values", () => {
|
||||
imageConfigState.cropLeft = 10;
|
||||
imageConfigState.cropTop = 20;
|
||||
imageConfigState.cropRight = 30;
|
||||
imageConfigState.cropBottom = 40;
|
||||
|
||||
expect(imageConfigState.cropLeft).toBe(10);
|
||||
expect(imageConfigState.cropTop).toBe(20);
|
||||
expect(imageConfigState.cropRight).toBe(30);
|
||||
expect(imageConfigState.cropBottom).toBe(40);
|
||||
});
|
||||
|
||||
it("should cleanup image event handlers on reset", async () => {
|
||||
await imageConfigState.uploadImageByLink("test.jpg");
|
||||
const img = imageConfigState.image;
|
||||
|
||||
imageConfigState.reset();
|
||||
|
||||
expect(img?.onload).toBeNull();
|
||||
expect(img?.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("should abort ongoing upload on reset", async () => {
|
||||
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
||||
|
||||
imageConfigState.reset();
|
||||
|
||||
await expect(upload).rejects.toThrow("Aborted");
|
||||
});
|
||||
|
||||
it("should cleanup resources on destroy", async () => {
|
||||
await imageConfigState.uploadImageByLink("test.jpg");
|
||||
const img = imageConfigState.image;
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
expect(imageConfigState.image).toBeUndefined();
|
||||
expect(img?.onload).toBeNull();
|
||||
expect(img?.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("should abort ongoing upload on destroy", async () => {
|
||||
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
await expect(upload).rejects.toThrow("Aborted");
|
||||
});
|
||||
|
||||
it("should cover aborted onload branch", async () => {
|
||||
const promise = imageConfigState.uploadImageByLink("test.png");
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
if (lastOnload) lastOnload();
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
});
|
||||
|
||||
it("should cover aborted onerror branch", async () => {
|
||||
const promise = imageConfigState.uploadImageByLink("test.png");
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
if (lastOnerror) lastOnerror();
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
});
|
||||
});
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState, ImageConfigState } from "$states/imageConfig.svelte";
|
||||
import { beforeEach, describe, expect, it, vi } 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("ImageConfigState", () => {
|
||||
beforeEach(() => {
|
||||
lastOnload = null;
|
||||
lastOnerror = null;
|
||||
imageConfigState.reset();
|
||||
});
|
||||
|
||||
it("should create new instance with default values", () => {
|
||||
const newState = new ImageConfigState();
|
||||
expect(newState.image).toBeUndefined();
|
||||
expect(newState.imageLink).toBe("");
|
||||
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 imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(true);
|
||||
expect(imageConfigState.image).toBeDefined();
|
||||
expect(imageConfigState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
});
|
||||
|
||||
it("should handle manual image upload correctly", async () => {
|
||||
const testLink = "https://example.com/test.png";
|
||||
const uploadPromise = imageConfigState.uploadImageByLink(testLink);
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
|
||||
await uploadPromise;
|
||||
expect(imageConfigState.imageReady).toBe(true);
|
||||
expect(imageConfigState.imageLink).toBe(testLink);
|
||||
});
|
||||
|
||||
it("should reset state to defaults", async () => {
|
||||
await imageConfigState.uploadImageByLink("some-image.png");
|
||||
imageConfigState.cropLeft = 100;
|
||||
|
||||
imageConfigState.reset();
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
expect(imageConfigState.imageLink).toBe("");
|
||||
expect(imageConfigState.cropLeft).toBe(0);
|
||||
expect(imageConfigState.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle image loading error", async () => {
|
||||
await expect(imageConfigState.uploadImageByLink("error-link")).rejects.toThrow(
|
||||
"Failed to load image",
|
||||
);
|
||||
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
});
|
||||
|
||||
it("should abort previous upload when new upload starts", async () => {
|
||||
const upload1 = imageConfigState.uploadImageByLink("test1.jpg");
|
||||
const upload2 = imageConfigState.uploadImageByLink("test2.jpg");
|
||||
|
||||
await expect(upload1).rejects.toThrow("Aborted");
|
||||
await expect(upload2).resolves.toBeUndefined();
|
||||
|
||||
expect(imageConfigState.imageLink).toBe("test2.jpg");
|
||||
});
|
||||
|
||||
it("should cleanup previous image before loading new one", async () => {
|
||||
await imageConfigState.uploadImageByLink("test1.jpg");
|
||||
const firstImage = imageConfigState.image;
|
||||
|
||||
await imageConfigState.uploadImageByLink("test2.jpg");
|
||||
|
||||
expect(firstImage?.onload).toBeNull();
|
||||
expect(firstImage?.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("should set crop values", () => {
|
||||
imageConfigState.cropLeft = 10;
|
||||
imageConfigState.cropTop = 20;
|
||||
imageConfigState.cropRight = 30;
|
||||
imageConfigState.cropBottom = 40;
|
||||
|
||||
expect(imageConfigState.cropLeft).toBe(10);
|
||||
expect(imageConfigState.cropTop).toBe(20);
|
||||
expect(imageConfigState.cropRight).toBe(30);
|
||||
expect(imageConfigState.cropBottom).toBe(40);
|
||||
});
|
||||
|
||||
it("should cleanup image event handlers on reset", async () => {
|
||||
await imageConfigState.uploadImageByLink("test.jpg");
|
||||
const img = imageConfigState.image;
|
||||
|
||||
imageConfigState.reset();
|
||||
|
||||
expect(img?.onload).toBeNull();
|
||||
expect(img?.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("should abort ongoing upload on reset", async () => {
|
||||
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
||||
|
||||
imageConfigState.reset();
|
||||
|
||||
await expect(upload).rejects.toThrow("Aborted");
|
||||
});
|
||||
|
||||
it("should cleanup resources on destroy", async () => {
|
||||
await imageConfigState.uploadImageByLink("test.jpg");
|
||||
const img = imageConfigState.image;
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
expect(imageConfigState.image).toBeUndefined();
|
||||
expect(img?.onload).toBeNull();
|
||||
expect(img?.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("should abort ongoing upload on destroy", async () => {
|
||||
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
await expect(upload).rejects.toThrow("Aborted");
|
||||
});
|
||||
|
||||
it("should cover aborted onload branch", async () => {
|
||||
const promise = imageConfigState.uploadImageByLink("test.png");
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
if (lastOnload) {
|
||||
lastOnload.call(new Image() as HTMLImageElement, new Event("load"));
|
||||
}
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
});
|
||||
|
||||
it("should cover aborted onerror branch", async () => {
|
||||
const promise = imageConfigState.uploadImageByLink("test.png");
|
||||
|
||||
imageConfigState.destroy();
|
||||
|
||||
if (lastOnerror) {
|
||||
lastOnerror.call(new Image() as HTMLImageElement, new Event("load"));
|
||||
}
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(imageConfigState.imageReady).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("konvaAllStages.svelte", () => {
|
||||
it("should import successfully", () => {
|
||||
expect(konvaAllStagesState).toBeDefined();
|
||||
});
|
||||
});
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("konvaAllStages.svelte", () => {
|
||||
it("should import successfully", () => {
|
||||
expect(konvaAllStagesState).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,14 @@
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("konvaStage.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have undefined stage initially", () => {
|
||||
expect(konvaStageState.stage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should have stage getter", () => {
|
||||
expect(konvaStageState).toHaveProperty("stage");
|
||||
});
|
||||
|
||||
it("should have stage setter", () => {
|
||||
expect(() => {
|
||||
konvaStageState.stage = {} as any;
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stage getter", () => {
|
||||
it("should return current stage", () => {
|
||||
const mockStage = { id: "test" } as any;
|
||||
konvaStageState.stage = mockStage;
|
||||
expect(konvaStageState.stage).toBeDefined();
|
||||
expect(konvaStageState.stage).toStrictEqual(mockStage);
|
||||
});
|
||||
|
||||
it("should be undefined only at initialization", () => {
|
||||
expect(konvaStageState.stage).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stage setter", () => {
|
||||
it("should set stage", () => {
|
||||
const mockStage = { id: "test" } as any;
|
||||
konvaStageState.stage = mockStage;
|
||||
|
||||
expect(konvaStageState.stage).toStrictEqual(mockStage);
|
||||
});
|
||||
|
||||
it("should allow updating stage", () => {
|
||||
const firstStage = { id: "first" } as any;
|
||||
const secondStage = { id: "second" } as any;
|
||||
|
||||
konvaStageState.stage = firstStage;
|
||||
expect(konvaStageState.stage).toStrictEqual(firstStage);
|
||||
|
||||
konvaStageState.stage = secondStage;
|
||||
expect(konvaStageState.stage).toStrictEqual(secondStage);
|
||||
expect(konvaStageState.stage).not.toStrictEqual(firstStage);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import type { Stage } from "svelte-konva";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("konvaStageState integration", () => {
|
||||
it("should work", () => {
|
||||
expect(konvaStageState.stage).toBeUndefined();
|
||||
|
||||
const mock = { name: "stage" } as unknown as Stage;
|
||||
konvaStageState.stage = mock;
|
||||
|
||||
expect(konvaStageState.stage).toStrictEqual(mock);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,158 +1,167 @@
|
||||
import { STATE_DATA, withPersistence } from "$states/persisted.svelte";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("persisted.svelte", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("withPersistence", () => {
|
||||
it("should return state unchanged when not in browser", () => {
|
||||
const mockState = {
|
||||
[STATE_DATA]: { test: "value" },
|
||||
};
|
||||
const result = withPersistence("test-key", mockState);
|
||||
expect(result).toBe(mockState);
|
||||
});
|
||||
|
||||
it("should restore state from localStorage when valid JSON exists", () => {
|
||||
const savedData = { value: 42, name: "test" };
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0, name: "" },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
expect(state[STATE_DATA]).toEqual(savedData);
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
|
||||
});
|
||||
|
||||
it("should handle corrupted JSON in localStorage gracefully", () => {
|
||||
localStorageMock.getItem.mockReturnValue("{ invalid json");
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0 },
|
||||
};
|
||||
|
||||
expect(() => withPersistence("test-key", state)).not.toThrow();
|
||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("should handle empty localStorage (no saved data)", () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("should save state to localStorage with debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = {
|
||||
[STATE_DATA]: { count: 1 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 500);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
await Promise.resolve();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should save state immediately when debounce is 0", async () => {
|
||||
const state = {
|
||||
[STATE_DATA]: { count: 1 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 0);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
|
||||
});
|
||||
|
||||
it("should cleanup timeout on state change during debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const data = $state({ count: 1 });
|
||||
|
||||
const state = {
|
||||
get [STATE_DATA]() {
|
||||
return data;
|
||||
},
|
||||
set [STATE_DATA](v) {
|
||||
data.count = v.count;
|
||||
},
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 500);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
await Promise.resolve();
|
||||
|
||||
state[STATE_DATA] = { count: 3 };
|
||||
await Promise.resolve();
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 3 }));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should use DEBOUNCE_DURATION constant as default", async () => {
|
||||
const state = {
|
||||
[STATE_DATA]: { test: true },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
state[STATE_DATA] = { test: false };
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
import { STATE_DATA, withPersistence } from "$states/persisted.svelte";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("persisted.svelte", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("withPersistence", () => {
|
||||
it("should return state unchanged when not in browser", () => {
|
||||
const mockState = {
|
||||
[STATE_DATA]: { test: "value" },
|
||||
};
|
||||
const result = withPersistence("test-key", mockState);
|
||||
expect(result).toBe(mockState);
|
||||
});
|
||||
|
||||
it("should restore state from localStorage when valid JSON exists", () => {
|
||||
const savedData = { value: 42, name: "test" };
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0, name: "" },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
expect(state[STATE_DATA]).toEqual(savedData);
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
|
||||
});
|
||||
|
||||
it("should handle corrupted JSON in localStorage gracefully", () => {
|
||||
localStorageMock.getItem.mockReturnValue("{ invalid json");
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0 },
|
||||
};
|
||||
|
||||
expect(() => withPersistence("test-key", state)).not.toThrow();
|
||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("should handle empty localStorage (no saved data)", () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("should save state to localStorage with debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = {
|
||||
[STATE_DATA]: { count: 1 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 500);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
await Promise.resolve();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"test-key",
|
||||
JSON.stringify({ count: 2 }),
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should save state immediately when debounce is 0", async () => {
|
||||
const state = {
|
||||
[STATE_DATA]: { count: 1 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 0);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"test-key",
|
||||
JSON.stringify({ count: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should cleanup timeout on state change during debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const data = $state({ count: 1 });
|
||||
|
||||
const state = {
|
||||
get [STATE_DATA]() {
|
||||
return data;
|
||||
},
|
||||
set [STATE_DATA](v) {
|
||||
data.count = v.count;
|
||||
},
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 500);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
await Promise.resolve();
|
||||
|
||||
state[STATE_DATA] = { count: 3 };
|
||||
await Promise.resolve();
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"test-key",
|
||||
JSON.stringify({ count: 3 }),
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should use DEBOUNCE_DURATION constant as default", async () => {
|
||||
const state = {
|
||||
[STATE_DATA]: { test: true },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
state[STATE_DATA] = { test: false };
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { TextAlign } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TEXT_ALIGN_VALUES = Object.values(TextAlign);
|
||||
|
||||
describe("textConfig.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have valid initial state structure", () => {
|
||||
expect(typeof textConfigState.fontSize).toBe("number");
|
||||
expect(typeof textConfigState.fontFamily).toBe("string");
|
||||
expect(typeof textConfigState.color).toBe("string");
|
||||
expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align);
|
||||
expect(typeof textConfigState.paddingX).toBe("number");
|
||||
expect(typeof textConfigState.offsetY).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("property setters", () => {
|
||||
it("should update all properties correctly", () => {
|
||||
textConfigState.fontSize = 32;
|
||||
textConfigState.fontFamily = "Roboto";
|
||||
textConfigState.color = "#ff0000" as HexColor;
|
||||
textConfigState.align = TextAlign.LEFT;
|
||||
textConfigState.paddingX = 20;
|
||||
textConfigState.offsetY = -10;
|
||||
|
||||
expect(textConfigState.fontSize).toBe(32);
|
||||
expect(textConfigState.fontFamily).toBe("Roboto");
|
||||
expect(textConfigState.color).toBe("#ff0000");
|
||||
expect(textConfigState.align).toBe(TextAlign.LEFT);
|
||||
expect(textConfigState.paddingX).toBe(20);
|
||||
expect(textConfigState.offsetY).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize state", () => {
|
||||
// Set custom values
|
||||
textConfigState.fontSize = 18;
|
||||
textConfigState.fontFamily = "Georgia";
|
||||
textConfigState.color = "#00ff00" as HexColor;
|
||||
textConfigState.align = TextAlign.RIGHT;
|
||||
textConfigState.paddingX = 5;
|
||||
textConfigState.offsetY = 15;
|
||||
|
||||
// Get serialized data
|
||||
const data = textConfigState[STATE_DATA];
|
||||
expect(data).toEqual({
|
||||
fontSize: 18,
|
||||
fontFamily: "Georgia",
|
||||
color: "#00ff00",
|
||||
align: TextAlign.RIGHT,
|
||||
paddingX: 5,
|
||||
offsetY: 15,
|
||||
});
|
||||
|
||||
// Restore from serialized data
|
||||
textConfigState[STATE_DATA] = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff" as HexColor,
|
||||
align: TextAlign.CENTER,
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
|
||||
expect(textConfigState.fontSize).toBe(24);
|
||||
expect(textConfigState.fontFamily).toBe("Arial");
|
||||
expect(textConfigState.color).toBe("#ffffff");
|
||||
expect(textConfigState.align).toBe(TextAlign.CENTER);
|
||||
expect(textConfigState.paddingX).toBe(10);
|
||||
expect(textConfigState.offsetY).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { TextAlign } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TEXT_ALIGN_VALUES = Object.values(TextAlign);
|
||||
|
||||
describe("textConfig.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have valid initial state structure", () => {
|
||||
expect(typeof textConfigState.fontSize).toBe("number");
|
||||
expect(typeof textConfigState.fontFamily).toBe("string");
|
||||
expect(typeof textConfigState.color).toBe("string");
|
||||
expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align);
|
||||
expect(typeof textConfigState.paddingX).toBe("number");
|
||||
expect(typeof textConfigState.offsetY).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("property setters", () => {
|
||||
it("should update all properties correctly", () => {
|
||||
textConfigState.fontSize = 32;
|
||||
textConfigState.fontFamily = "Roboto";
|
||||
textConfigState.color = "#ff0000" as HexColor;
|
||||
textConfigState.align = TextAlign.LEFT;
|
||||
textConfigState.paddingX = 20;
|
||||
textConfigState.offsetY = -10;
|
||||
|
||||
expect(textConfigState.fontSize).toBe(32);
|
||||
expect(textConfigState.fontFamily).toBe("Roboto");
|
||||
expect(textConfigState.color).toBe("#ff0000");
|
||||
expect(textConfigState.align).toBe(TextAlign.LEFT);
|
||||
expect(textConfigState.paddingX).toBe(20);
|
||||
expect(textConfigState.offsetY).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize state", () => {
|
||||
// Set custom values
|
||||
textConfigState.fontSize = 18;
|
||||
textConfigState.fontFamily = "Georgia";
|
||||
textConfigState.color = "#00ff00" as HexColor;
|
||||
textConfigState.align = TextAlign.RIGHT;
|
||||
textConfigState.paddingX = 5;
|
||||
textConfigState.offsetY = 15;
|
||||
|
||||
// Get serialized data
|
||||
const data = textConfigState[STATE_DATA];
|
||||
expect(data).toEqual({
|
||||
fontSize: 18,
|
||||
fontFamily: "Georgia",
|
||||
color: "#00ff00",
|
||||
align: TextAlign.RIGHT,
|
||||
paddingX: 5,
|
||||
offsetY: 15,
|
||||
});
|
||||
|
||||
// Restore from serialized data
|
||||
textConfigState[STATE_DATA] = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff" as HexColor,
|
||||
align: TextAlign.CENTER,
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
|
||||
expect(textConfigState.fontSize).toBe(24);
|
||||
expect(textConfigState.fontFamily).toBe("Arial");
|
||||
expect(textConfigState.color).toBe("#ffffff");
|
||||
expect(textConfigState.align).toBe(TextAlign.CENTER);
|
||||
expect(textConfigState.paddingX).toBe(10);
|
||||
expect(textConfigState.offsetY).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { createState } from "$states/texts.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("texts.svelte", () => {
|
||||
describe("addText", () => {
|
||||
it("should add new text with unique ID", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.addText("Test text");
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength + 1);
|
||||
expect(state.texts[initialLength].text).toBe("Test text");
|
||||
expect(state.texts[initialLength].id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not add empty text", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.addText("");
|
||||
state.addText(" ");
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeText", () => {
|
||||
it("should remove text by ID", () => {
|
||||
const state = createState();
|
||||
const idToRemove = state.texts[0].id;
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.removeText(idToRemove);
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength - 1);
|
||||
expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not affect other texts when removing", () => {
|
||||
const state = createState();
|
||||
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
|
||||
const idToRemove = state.texts[0].id;
|
||||
|
||||
state.removeText(idToRemove);
|
||||
|
||||
expect(state.texts).toStrictEqual(remainingTexts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should remove all texts", () => {
|
||||
const state = createState();
|
||||
state.addText("Test 1");
|
||||
state.addText("Test 2");
|
||||
state.addText("Test 3");
|
||||
|
||||
state.clear();
|
||||
|
||||
expect(state.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize texts", () => {
|
||||
const state = createState();
|
||||
state.clear();
|
||||
state.addText("First");
|
||||
state.addText("Second");
|
||||
state.addText("Third");
|
||||
|
||||
const data = state[STATE_DATA];
|
||||
expect(data).toEqual(["First", "Second", "Third"]);
|
||||
|
||||
// Restore from serialized data
|
||||
state[STATE_DATA] = ["New 1", "New 2"];
|
||||
|
||||
expect(state.texts).toHaveLength(2);
|
||||
expect(state.texts[0].text).toBe("New 1");
|
||||
expect(state.texts[1].text).toBe("New 2");
|
||||
expect(state.texts[0].id).toBe(0);
|
||||
expect(state.texts[1].id).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const state = createState();
|
||||
state.addText("Test");
|
||||
|
||||
state[STATE_DATA] = [];
|
||||
|
||||
expect(state.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { createState } from "$states/texts.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("texts.svelte", () => {
|
||||
describe("addText", () => {
|
||||
it("should add new text with unique ID", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.addText("Test text");
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength + 1);
|
||||
expect(state.texts[initialLength].text).toBe("Test text");
|
||||
expect(state.texts[initialLength].id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not add empty text", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.addText("");
|
||||
state.addText(" ");
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeText", () => {
|
||||
it("should remove text by ID", () => {
|
||||
const state = createState();
|
||||
const idToRemove = state.texts[0].id;
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.removeText(idToRemove);
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength - 1);
|
||||
expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not affect other texts when removing", () => {
|
||||
const state = createState();
|
||||
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
|
||||
const idToRemove = state.texts[0].id;
|
||||
|
||||
state.removeText(idToRemove);
|
||||
|
||||
expect(state.texts).toStrictEqual(remainingTexts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should remove all texts", () => {
|
||||
const state = createState();
|
||||
state.addText("Test 1");
|
||||
state.addText("Test 2");
|
||||
state.addText("Test 3");
|
||||
|
||||
state.clear();
|
||||
|
||||
expect(state.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize texts", () => {
|
||||
const state = createState();
|
||||
state.clear();
|
||||
state.addText("First");
|
||||
state.addText("Second");
|
||||
state.addText("Third");
|
||||
|
||||
const data = state[STATE_DATA];
|
||||
expect(data).toEqual(["First", "Second", "Third"]);
|
||||
|
||||
// Restore from serialized data
|
||||
state[STATE_DATA] = ["New 1", "New 2"];
|
||||
|
||||
expect(state.texts).toHaveLength(2);
|
||||
expect(state.texts[0].text).toBe("New 1");
|
||||
expect(state.texts[1].text).toBe("New 2");
|
||||
expect(state.texts[0].id).toBe(0);
|
||||
expect(state.texts[1].id).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const state = createState();
|
||||
state.addText("Test");
|
||||
|
||||
state[STATE_DATA] = [];
|
||||
|
||||
expect(state.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { Theme } from "$lib/constants";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const THEME_VALUES = Object.values(Theme);
|
||||
|
||||
describe("theme.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have valid initial theme value", () => {
|
||||
expect(THEME_VALUES).toContain(themeState.current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("should toggle between themes", () => {
|
||||
themeState.current = Theme.LIGHT;
|
||||
themeState.toggle();
|
||||
expect(themeState.current).toBe(Theme.DARK);
|
||||
|
||||
themeState.toggle();
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current setter", () => {
|
||||
it("should set theme correctly", () => {
|
||||
themeState.current = Theme.DARK;
|
||||
expect(themeState.current).toBe(Theme.DARK);
|
||||
|
||||
themeState.current = Theme.LIGHT;
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize theme", () => {
|
||||
themeState.current = Theme.DARK;
|
||||
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK });
|
||||
|
||||
themeState[STATE_DATA] = { current: Theme.LIGHT };
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { Theme } from "$lib/constants";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const THEME_VALUES = Object.values(Theme);
|
||||
|
||||
describe("theme.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have valid initial theme value", () => {
|
||||
expect(THEME_VALUES).toContain(themeState.current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("should toggle between themes", () => {
|
||||
themeState.current = Theme.LIGHT;
|
||||
themeState.toggle();
|
||||
expect(themeState.current).toBe(Theme.DARK);
|
||||
|
||||
themeState.toggle();
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current setter", () => {
|
||||
it("should set theme correctly", () => {
|
||||
themeState.current = Theme.DARK;
|
||||
expect(themeState.current).toBe(Theme.DARK);
|
||||
|
||||
themeState.current = Theme.LIGHT;
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize theme", () => {
|
||||
themeState.current = Theme.DARK;
|
||||
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK });
|
||||
|
||||
themeState[STATE_DATA] = { current: Theme.LIGHT };
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user