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
|
name: Deploy SvelteKit
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- "tsconfig.json"
|
- "tsconfig.json"
|
||||||
- "svelte.config.js"
|
- "svelte.config.js"
|
||||||
- "vite.config.js"
|
- "vite.config.js"
|
||||||
- ".github/workflows/**"
|
- ".github/workflows/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Clean _app directory
|
- name: Clean _app directory
|
||||||
run: |
|
run: |
|
||||||
rm -rf build/_app
|
rm -rf build/_app
|
||||||
touch build/.nojekyll
|
touch build/.nojekyll
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v4
|
uses: actions/configure-pages@v4
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: ./build
|
path: ./build
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
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"],
|
"extends": ["stylelint-config-recommended", "stylelint-config-html/svelte"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"selector-pseudo-class-no-unknown": [
|
"selector-pseudo-class-no-unknown": [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
"ignorePseudoClasses": ["global"]
|
"ignorePseudoClasses": ["global"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ignoreFiles": [
|
"ignoreFiles": [
|
||||||
".github/**/*",
|
".github/**/*",
|
||||||
".svelte-kit/**/*",
|
".svelte-kit/**/*",
|
||||||
"build/**/*",
|
"build/**/*",
|
||||||
"coverage/**/*",
|
"coverage/**/*",
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"node_modules/**/*",
|
"node_modules/**/*",
|
||||||
"reference/**/*"
|
"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",
|
"name": "twitch-panels",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"lint:css": "stylelint '**/*.{css,svelte}",
|
"lint:css": "stylelint '**/*.{css,svelte}",
|
||||||
"check:css": "node scripts/css-vars.js",
|
"check:css": "node scripts/css-vars.js",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:all": "npm run check && npm run check:css && npm run lint:css && npm run lint && npm run test:run",
|
||||||
"test": "vitest",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test:ui": "vitest --ui",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:run": "vitest run",
|
||||||
"test:unit": "vitest run tests/unit",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:e2e": "playwright test"
|
"test:integration": "vitest run tests/integration",
|
||||||
},
|
"test:e2e": "playwright test",
|
||||||
"devDependencies": {
|
"lint": "prettier --check . && eslint .",
|
||||||
"@iconify-json/lucide": "^1.2.89",
|
"format": "prettier --write ."
|
||||||
"@playwright/test": "^1.58.1",
|
},
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@eslint/compat": "^2.0.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@iconify-json/lucide": "^1.2.89",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@playwright/test": "^1.58.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@types/node": "^25.1.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@vitest/coverage-istanbul": "^4.0.18",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"glob": "^13.0.2",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"jsdom": "^28.0.0",
|
"@types/file-saver": "^2.0.7",
|
||||||
"konva": "^10.2.0",
|
"@types/node": "^20",
|
||||||
"stylelint": "^17.2.0",
|
"@vitest/coverage-istanbul": "^4.0.18",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"@vitest/ui": "^4.0.18",
|
||||||
"stylelint-config-recommended": "^18.0.0",
|
"eslint": "^9.39.2",
|
||||||
"svelte": "^5.48.2",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"svelte-check": "^4.3.5",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"typescript": "^5.9.3",
|
"glob": "^13.0.2",
|
||||||
"unplugin-icons": "^23.0.1",
|
"globals": "^17.3.0",
|
||||||
"vite": "^7.3.1",
|
"jsdom": "^28.0.0",
|
||||||
"vitest": "^4.0.18",
|
"konva": "^10.2.0",
|
||||||
"vitest-canvas-mock": "^1.1.3",
|
"prettier": "^3.8.1",
|
||||||
"web-animations-js": "^2.3.2"
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
},
|
"stylelint": "^17.2.0",
|
||||||
"dependencies": {
|
"stylelint-config-html": "^1.1.0",
|
||||||
"@types/cropperjs": "^1.1.5",
|
"stylelint-config-recommended": "^18.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"svelte": "^5.48.2",
|
||||||
"cropperjs": "^2.1.0",
|
"svelte-check": "^4.3.5",
|
||||||
"file-saver": "^2.0.5",
|
"typescript": "^5.9.3",
|
||||||
"jszip": "^3.10.1",
|
"typescript-eslint": "^8.54.0",
|
||||||
"svelte-konva": "^1.0.1",
|
"unplugin-icons": "^23.0.1",
|
||||||
"uuid": "^13.0.0"
|
"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)
|
# Функциональный план (Product & UX Roadmap)
|
||||||
|
|
||||||
## Twitch Panels Creator
|
## Twitch Panels Creator
|
||||||
|
|
||||||
**Анализ версии:** 0.0.1
|
**Анализ версии:** 0.0.1
|
||||||
**Дата анализа:** 2025-02-09
|
**Дата анализа:** 2025-02-09
|
||||||
**Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js
|
**Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Текущие возможности (глазами пользователя)
|
## 1. Текущие возможности (глазами пользователя)
|
||||||
|
|
||||||
### ✅ Реализованный функционал
|
### ✅ Реализованный функционал
|
||||||
|
|
||||||
#### 1.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)
|
**Файлы:** [`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
|
- Добавление текстовых панелей через инпут + кнопку/Enter
|
||||||
- Редактирование текста инлайн (двойной клик или прямое редактирование)
|
- Редактирование текста инлайн (двойной клик или прямое редактирование)
|
||||||
- Удаление отдельных текстов
|
- Удаление отдельных текстов
|
||||||
- Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение)
|
- Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение)
|
||||||
- **Приоритет:** High - это основной рабочий поток
|
- **Приоритет:** High - это основной рабочий поток
|
||||||
|
|
||||||
#### 1.2 Система превью панелей
|
#### 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)
|
**Файлы:** [`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")
|
- Навигация между панелями (стрелки + индикатор "N / M")
|
||||||
- Просмотр всех панелей одновременно (в скрытом контейнере для экспорта)
|
- Просмотр всех панелей одновременно (в скрытом контейнере для экспорта)
|
||||||
- Плавные переходы между панелями (fly transition)
|
- Плавные переходы между панелями (fly transition)
|
||||||
- **Приоритет:** High - критично для UX
|
- **Приоритет:** High - критично для UX
|
||||||
|
|
||||||
#### 1.3 Экспорт панелей
|
#### 1.3 Экспорт панелей
|
||||||
|
|
||||||
**Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts)
|
**Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts)
|
||||||
|
|
||||||
- Экспорт отдельной панели в PNG (320x100px)
|
- Экспорт отдельной панели в PNG (320x100px)
|
||||||
- Массовый экспорт всех панелей в ZIP-архив
|
- Массовый экспорт всех панелей в ZIP-архив
|
||||||
- Использование file-saver и JSZip
|
- Использование file-saver и JSZip
|
||||||
- **Приоритет:** High - финальная цель пользователя
|
- **Приоритет:** High - финальная цель пользователя
|
||||||
|
|
||||||
#### 1.4 Система фоновых изображений (частичная)
|
#### 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)
|
**Файлы:** [`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 кнопки)
|
- Загрузка изображения по URL (только UI кнопки)
|
||||||
- Визуальный интерфейс для обрезки (crop box с handles)
|
- Визуальный интерфейс для обрезки (crop box с handles)
|
||||||
- Настройки яркости и контраста (только UI слайдеры)
|
- Настройки яркости и контраста (только UI слайдеры)
|
||||||
- Автозагрузка дефолтного фона при старте
|
- Автозагрузка дефолтного фона при старте
|
||||||
- **Приоритет:** Low - нерабочая система
|
- **Приоритет:** Low - нерабочая система
|
||||||
|
|
||||||
#### 1.5 Базовый UI/UX
|
#### 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)
|
**Файлы:** [`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)
|
- Кнопки 5 типов (primary, secondary, outline, danger, mini)
|
||||||
- Слайдеры с отображением значения
|
- Слайдеры с отображением значения
|
||||||
- Цветовой пикер
|
- Цветовой пикер
|
||||||
- Выбор шрифта из 8 системных
|
- Выбор шрифта из 8 системных
|
||||||
- Три кнопки выравнивания
|
- Три кнопки выравнивания
|
||||||
- Темная/светлая тема
|
- Темная/светлая тема
|
||||||
- **Приоритет:** Medium - работает, но можно улучшить
|
- **Приоритет:** Medium - работает, но можно улучшить
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Low-hanging fruit (Ближайшие улучшения)
|
## 2. Low-hanging fruit (Ближайшие улучшения)
|
||||||
|
|
||||||
### 2.1 Завершение системы изображений (High Priority)
|
### 2.1 Завершение системы изображений (High Priority)
|
||||||
|
|
||||||
**Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки.
|
**Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки.
|
||||||
|
|
||||||
**Требуемые правки:**
|
**Требуемые правки:**
|
||||||
|
|
||||||
1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts):
|
1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts):
|
||||||
- Drag & drop поддержка
|
- Drag & drop поддержка
|
||||||
- Paste из буфера обмена
|
- Paste из буфера обмена
|
||||||
- Загрузка по URL с валидацией
|
- Загрузка по URL с валидацией
|
||||||
- Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max)
|
- Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max)
|
||||||
- **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>`
|
- **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>`
|
||||||
|
|
||||||
2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte):
|
2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte):
|
||||||
- Инициализация cropper.js на canvas
|
- Инициализация cropper.js на canvas
|
||||||
- Привязка crop box к данным состояния
|
- Привязка crop box к данным состояния
|
||||||
- Реализовать drag & drop для перемещения
|
- Реализовать drag & drop для перемещения
|
||||||
- Реализовать resize через 8 handles
|
- Реализовать resize через 8 handles
|
||||||
- **Контракт:** `onCropChange(crop: { left, top, right, bottom })`
|
- **Контракт:** `onCropChange(crop: { left, top, right, bottom })`
|
||||||
|
|
||||||
3. **Применить фильтры яркости/контраста**:
|
3. **Применить фильтры яркости/контраста**:
|
||||||
- В [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts) добавить `brightness: number`, `contrast: number`
|
- В [`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)
|
- Применить CSS filters к Konva Image в [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte)
|
||||||
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
|
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
|
||||||
|
|
||||||
4. **Сохранить обрезанное изображение**:
|
4. **Сохранить обрезанное изображение**:
|
||||||
- Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными
|
- Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными
|
||||||
- Использовать canvas для actual cropping
|
- Использовать canvas для actual cropping
|
||||||
|
|
||||||
### 2.2 Валидация и состояния загрузки (Medium Priority)
|
### 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)
|
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить валидацию длины (max 100 chars из [`src/lib/constants.ts`](src/lib/constants.ts):32)
|
||||||
- Показать ошибку при превышении длины
|
- Показать ошибку при превышении длины
|
||||||
- Добавить счетчик символов "3/100"
|
- Добавить счетчик символов "3/100"
|
||||||
|
|
||||||
**Изображения:**
|
**Изображения:**
|
||||||
|
|
||||||
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL
|
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL
|
||||||
- Показать ошибку при неудачной загрузке
|
- Показать ошибку при неудачной загрузке
|
||||||
- Валидация формата/размера до загрузки
|
- Валидация формата/размера до загрузки
|
||||||
|
|
||||||
**Экспорт:**
|
**Экспорт:**
|
||||||
|
|
||||||
- [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()`
|
- [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()`
|
||||||
- Отключить кнопки во время экспорта
|
- Отключить кнопки во время экспорта
|
||||||
- Показать toast уведомление об успехе/ошибке
|
- Показать toast уведомление об успехе/ошибке
|
||||||
|
|
||||||
### 2.3 Пустые состояния (Medium Priority)
|
### 2.3 Пустые состояния (Medium Priority)
|
||||||
|
|
||||||
**Списки:**
|
**Списки:**
|
||||||
|
|
||||||
- [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte): когда `textsState.texts.length === 0`, показать красивый empty state вместо пустого UL
|
- [`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/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte): уже есть empty state, но можно улучшить (иконка + текст + CTA)
|
||||||
|
|
||||||
**Изображение:**
|
**Изображение:**
|
||||||
|
|
||||||
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией
|
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией
|
||||||
|
|
||||||
### 2.4 Улучшение доступности (Medium Priority)
|
### 2.4 Улучшение доступности (Medium Priority)
|
||||||
|
|
||||||
**Файлы для правок:**
|
**Файлы для правок:**
|
||||||
|
|
||||||
- Все кнопки уже имеют `aria-label` ✅
|
- Все кнопки уже имеют `aria-label` ✅
|
||||||
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить `aria-describedby` для валидации
|
- [`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 (уже есть)
|
- [`src/components/text/TextInlineEdit.svelte`](src/components/text/TextInlineEdit.svelte): добавить `aria-label` на delete button (уже есть)
|
||||||
- Добавить `role="region"` и `aria-label` на карточки
|
- Добавить `role="region"` и `aria-label` на карточки
|
||||||
- Убедиться, что фокусный порядок логичен (Tab navigation)
|
- Убедиться, что фокусный порядок логичен (Tab navigation)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Масштабирование (Крупные модули/интеграции)
|
## 3. Масштабирование (Крупные модули/интеграции)
|
||||||
|
|
||||||
### 3.1 Сохранение и загрузка проектов (High Priority)
|
### 3.1 Сохранение и загрузка проектов (High Priority)
|
||||||
|
|
||||||
**Проблема:** Нет persistence, пользователь теряет данные при перезагрузке.
|
**Проблема:** Нет persistence, пользователь теряет данные при перезагрузке.
|
||||||
|
|
||||||
**Решение:**
|
**Решение:**
|
||||||
|
|
||||||
1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):**
|
1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):**
|
||||||
- `saveProject(project: Project): Promise<void>` → localStorage
|
- `saveProject(project: Project): Promise<void>` → localStorage
|
||||||
- `loadProject(id: string): Project | null`
|
- `loadProject(id: string): Project | null`
|
||||||
- `listProjects(): ProjectInfo[]`
|
- `listProjects(): ProjectInfo[]`
|
||||||
- `deleteProject(id: string)`
|
- `deleteProject(id: string)`
|
||||||
- **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }`
|
- **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }`
|
||||||
|
|
||||||
2. **Добавить UI для управления проектами:**
|
2. **Добавить UI для управления проектами:**
|
||||||
- [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку
|
- [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку
|
||||||
- [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый):
|
- [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый):
|
||||||
- Список сохраненных проектов
|
- Список сохраненных проектов
|
||||||
- Создание/переименование/удаление
|
- Создание/переименование/удаление
|
||||||
- Экспорт/импорт JSON (для бэкапа)
|
- Экспорт/импорт JSON (для бэкапа)
|
||||||
|
|
||||||
3. **Автосохранение:**
|
3. **Автосохранение:**
|
||||||
- В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save
|
- В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save
|
||||||
- Восстановление при загрузке страницы
|
- Восстановление при загрузке страницы
|
||||||
|
|
||||||
**User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам.
|
**User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам.
|
||||||
|
|
||||||
### 3.2 Расширенные настройки текста (Medium Priority)
|
### 3.2 Расширенные настройки текста (Medium Priority)
|
||||||
|
|
||||||
**Файлы для модификации:** [`src/states/textConfig.svelte.ts`](src/states/textConfig.svelte.ts), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
|
**Файлы для модификации:** [`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 }`
|
- **Тень текста:** `textShadow: { offsetX, offsetY, blur, color }`
|
||||||
- **Прозрачность:** `opacity: number` (0-100)
|
- **Прозрачность:** `opacity: number` (0-100)
|
||||||
- **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее)
|
- **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее)
|
||||||
- **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte
|
- **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte
|
||||||
|
|
||||||
**Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva.
|
**Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva.
|
||||||
|
|
||||||
### 3.3 Расширенные настройки изображений (Medium Priority)
|
### 3.3 Расширенные настройки изображений (Medium Priority)
|
||||||
|
|
||||||
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte)
|
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte)
|
||||||
|
|
||||||
Добавить:
|
Добавить:
|
||||||
|
|
||||||
- **Фильтры:** blur, sepia, saturate, grayscale
|
- **Фильтры:** blur, sepia, saturate, grayscale
|
||||||
- **Наложение:** возможность добавить второй слой изображения
|
- **Наложение:** возможность добавить второй слой изображения
|
||||||
- **Режимы наложения:** multiply, screen, overlay (CSS blend modes)
|
- **Режимы наложения:** multiply, screen, overlay (CSS blend modes)
|
||||||
|
|
||||||
**Приоритет:** Medium - улучшает креативность, но сложная реализация.
|
**Приоритет:** Medium - улучшает креативность, но сложная реализация.
|
||||||
|
|
||||||
### 3.4 Пресеты и шаблоны (Low Priority)
|
### 3.4 Пресеты и шаблоны (Low Priority)
|
||||||
|
|
||||||
**Новые файлы:**
|
**Новые файлы:**
|
||||||
|
|
||||||
- [`src/services/templateService.ts`](src/services/templateService.ts)
|
- [`src/services/templateService.ts`](src/services/templateService.ts)
|
||||||
- [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte)
|
- [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte)
|
||||||
|
|
||||||
**Функционал:**
|
**Функционал:**
|
||||||
|
|
||||||
- Предустановленные шаблоны (разные стили текста/изображений)
|
- Предустановленные шаблоны (разные стили текста/изображений)
|
||||||
- Сохранение пользовательских пресетов
|
- Сохранение пользовательских пресетов
|
||||||
- Применение пресета к текущему проекту
|
- Применение пресета к текущему проекту
|
||||||
|
|
||||||
**Приоритет:** Low - nice-to-have, не критично для MVP.
|
**Приоритет:** Low - nice-to-have, не критично для MVP.
|
||||||
|
|
||||||
### 3.5 Интеграция с Twitch API (Future)
|
### 3.5 Интеграция с Twitch API (Future)
|
||||||
|
|
||||||
**Возможные интеграции:**
|
**Возможные интеграции:**
|
||||||
|
|
||||||
- Прямая загрузка панелей на Twitch через API
|
- Прямая загрузка панелей на Twitch через API
|
||||||
- Синхронизация с существующими панелями
|
- Синхронизация с существующими панелями
|
||||||
- Планирование публикации
|
- Планирование публикации
|
||||||
|
|
||||||
**Приоритет:** Low - требует OAuth, сложная интеграция.
|
**Приоритет:** Low - требует OAuth, сложная интеграция.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. User Retention (Повторное использование)
|
## 4. User Retention (Повторное использование)
|
||||||
|
|
||||||
### 4.1 Сохранение проектов (см. 3.1) - HIGH
|
### 4.1 Сохранение проектов (см. 3.1) - HIGH
|
||||||
|
|
||||||
### 4.2 Настройки пользователя (Medium Priority)
|
### 4.2 Настройки пользователя (Medium Priority)
|
||||||
|
|
||||||
**Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения.
|
**Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения.
|
||||||
|
|
||||||
**Добавить:**
|
**Добавить:**
|
||||||
|
|
||||||
- Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15)
|
- Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15)
|
||||||
- Сохранение предпочитаемого шрифта
|
- Сохранение предпочитаемого шрифта
|
||||||
- Сохранение последних использованных цветов
|
- Сохранение последних использованных цветов
|
||||||
- **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts)
|
- **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts)
|
||||||
|
|
||||||
### 4.3 Экспорт в разные форматы (Medium Priority)
|
### 4.3 Экспорт в разные форматы (Medium Priority)
|
||||||
|
|
||||||
**Текущее состояние:** Только PNG.
|
**Текущее состояние:** Только PNG.
|
||||||
|
|
||||||
**Добавить:**
|
**Добавить:**
|
||||||
|
|
||||||
- Экспорт в JPEG (с настройкой качества)
|
- Экспорт в JPEG (с настройкой качества)
|
||||||
- Экспорт в WebP (современный формат)
|
- Экспорт в WebP (современный формат)
|
||||||
- Экспорт в PDF (для печати)
|
- Экспорт в PDF (для печати)
|
||||||
- **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService)
|
- **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService)
|
||||||
|
|
||||||
**Приоритет:** Medium - увеличивает полезность.
|
**Приоритет:** Medium - увеличивает полезность.
|
||||||
|
|
||||||
### 4.4 История изменений (Low Priority)
|
### 4.4 История изменений (Low Priority)
|
||||||
|
|
||||||
**Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts)
|
**Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts)
|
||||||
|
|
||||||
**Функционал:**
|
**Функционал:**
|
||||||
|
|
||||||
- Undo/Redo для текстов и настроек
|
- Undo/Redo для текстов и настроек
|
||||||
- Хранение N последних состояний
|
- Хранение N последних состояний
|
||||||
- Горячие клавиши Ctrl+Z / Ctrl+Y
|
- Горячие клавиши Ctrl+Z / Ctrl+Y
|
||||||
|
|
||||||
**Приоритет:** Low - удобно, но не обязательно.
|
**Приоритет:** Low - удобно, но не обязательно.
|
||||||
|
|
||||||
### 4.5 Совместная работа (Future)
|
### 4.5 Совместная работа (Future)
|
||||||
|
|
||||||
**Сложная интеграция:**
|
**Сложная интеграция:**
|
||||||
|
|
||||||
- Share link с состоянием (закодировать в URL)
|
- Share link с состоянием (закодировать в URL)
|
||||||
- Real-time collaboration (WebSocket)
|
- Real-time collaboration (WebSocket)
|
||||||
- Комментарии/ревью
|
- Комментарии/ревью
|
||||||
|
|
||||||
**Приоритет:** Very Low - далеко от MVP.
|
**Приоритет:** Very Low - далеко от MVP.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Приоритизация по фазам
|
## 5. Приоритизация по фазам
|
||||||
|
|
||||||
### Фаза 1: Завершение базового функционала (2-3 недели)
|
### Фаза 1: Завершение базового функционала (2-3 недели)
|
||||||
|
|
||||||
**Цель:** Приложение должно работать end-to-end
|
**Цель:** Приложение должно работать end-to-end
|
||||||
|
|
||||||
1. Полная реализация системы изображений (2.1) - **High**
|
1. Полная реализация системы изображений (2.1) - **High**
|
||||||
2. Сохранение проектов (3.1) - **High**
|
2. Сохранение проектов (3.1) - **High**
|
||||||
3. Валидация и состояния загрузки (2.2) - **Medium**
|
3. Валидация и состояния загрузки (2.2) - **Medium**
|
||||||
4. Пустые состояния (2.3) - **Medium**
|
4. Пустые состояния (2.3) - **Medium**
|
||||||
5. Настройки пользователя (4.2) - **Medium**
|
5. Настройки пользователя (4.2) - **Medium**
|
||||||
|
|
||||||
**Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом.
|
**Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом.
|
||||||
|
|
||||||
### Фаза 2: Улучшение UX и расширение (3-4 недели)
|
### Фаза 2: Улучшение UX и расширение (3-4 недели)
|
||||||
|
|
||||||
1. Расширенные настройки текста (3.2) - **Medium**
|
1. Расширенные настройки текста (3.2) - **Medium**
|
||||||
2. Расширенные настройки изображений (3.3) - **Medium**
|
2. Расширенные настройки изображений (3.3) - **Medium**
|
||||||
3. Экспорт в разные форматы (4.3) - **Medium**
|
3. Экспорт в разные форматы (4.3) - **Medium**
|
||||||
4. Улучшение доступности (2.4) - **Medium**
|
4. Улучшение доступности (2.4) - **Medium**
|
||||||
5. Пресеты и шаблоны (3.4) - **Low**
|
5. Пресеты и шаблоны (3.4) - **Low**
|
||||||
|
|
||||||
**Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили.
|
**Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили.
|
||||||
|
|
||||||
### Фаза 3: Продвинутые фичи (4+ недели)
|
### Фаза 3: Продвинутые фичи (4+ недели)
|
||||||
|
|
||||||
1. История изменений (4.4) - **Low**
|
1. История изменений (4.4) - **Low**
|
||||||
2. Интеграция с Twitch API (3.5) - **Low**
|
2. Интеграция с Twitch API (3.5) - **Low**
|
||||||
3. Совместная работа (4.5) - **Very Low**
|
3. Совместная работа (4.5) - **Very Low**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Метрики успеха
|
## 6. Метрики успеха
|
||||||
|
|
||||||
- **Активация:** >70% пользователей создают хотя бы 1 панель
|
- **Активация:** >70% пользователей создают хотя бы 1 панель
|
||||||
- **Сохранение:** >30% пользователей сохраняют проект
|
- **Сохранение:** >30% пользователей сохраняют проект
|
||||||
- **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель
|
- **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель
|
||||||
- **Время на создание:** <2 минут для набора из 5 панелей
|
- **Время на создание:** <2 минут для набора из 5 панелей
|
||||||
- **Retention week 1:** >40% возвращаются
|
- **Retention week 1:** >40% возвращаются
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Риски и ограничения
|
## 7. Риски и ограничения
|
||||||
|
|
||||||
1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte)
|
1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte)
|
||||||
2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях
|
2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях
|
||||||
3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений
|
3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений
|
||||||
4. **Cropper.js:** Требует доработки для работы с canvas/Konva
|
4. **Cropper.js:** Требует доработки для работы с canvas/Konva
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Следующие шаги:**
|
**Следующие шаги:**
|
||||||
|
|
||||||
1. Утвердить функциональный план
|
1. Утвердить функциональный план
|
||||||
2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md)
|
2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md)
|
||||||
3. Начать реализацию Фазы 1 (начиная с imageService)
|
3. Начать реализацию Фазы 1 (начиная с imageService)
|
||||||
|
|||||||
+1145
-1145
File diff suppressed because it is too large
Load Diff
+288
-250
@@ -1,250 +1,288 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Twitch Panels Creator</title>
|
<title>Twitch Panels Creator</title>
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1>Twitch Panels</h1>
|
<h1>Twitch Panels</h1>
|
||||||
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
|
<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">
|
<svg
|
||||||
<circle cx="12" cy="12" r="5"></circle>
|
class="sun-icon"
|
||||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
viewBox="0 0 24 24"
|
||||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
fill="none"
|
||||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
stroke="currentColor"
|
||||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
stroke-width="2"
|
||||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
>
|
||||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
<circle cx="12" cy="12" r="5"></circle>
|
||||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||||
</svg>
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||||
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||||
</svg>
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||||
</button>
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||||
</div>
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||||
</header>
|
</svg>
|
||||||
|
<svg
|
||||||
<div class="main-grid">
|
class="moon-icon"
|
||||||
<!-- Left Column -->
|
viewBox="0 0 24 24"
|
||||||
<div class="left-column">
|
fill="none"
|
||||||
<section class="card">
|
stroke="currentColor"
|
||||||
<h2 class="card-title">Тексты панелей</h2>
|
stroke-width="2"
|
||||||
|
>
|
||||||
<div class="input-group">
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||||
<input type="text" id="panelTextInput" placeholder="Введите текст..." class="text-input" />
|
</svg>
|
||||||
<button id="addTextBtn" class="btn btn-primary">
|
</button>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
</div>
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
</header>
|
||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
||||||
</svg>
|
<div class="main-grid">
|
||||||
</button>
|
<!-- Left Column -->
|
||||||
</div>
|
<div class="left-column">
|
||||||
|
<section class="card">
|
||||||
<div id="textsList" class="texts-list"></div>
|
<h2 class="card-title">Тексты панелей</h2>
|
||||||
</section>
|
|
||||||
|
<div class="input-group">
|
||||||
<section class="card">
|
<input
|
||||||
<h2 class="card-title">Настройки текста</h2>
|
type="text"
|
||||||
|
id="panelTextInput"
|
||||||
<div class="settings-grid">
|
placeholder="Введите текст..."
|
||||||
<div class="setting-row">
|
class="text-input"
|
||||||
<label class="setting-label">Размер</label>
|
/>
|
||||||
<div class="setting-control">
|
<button id="addTextBtn" class="btn btn-primary">
|
||||||
<input type="range" id="fontSize" min="12" max="48" value="18" class="slider" />
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<span class="value-display" id="fontSizeValue">18</span>
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
</div>
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
</div>
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="setting-row">
|
</div>
|
||||||
<label class="setting-label">Шрифт</label>
|
|
||||||
<select id="fontFamily" class="select-input">
|
<div id="textsList" class="texts-list"></div>
|
||||||
<option value="Arial">Arial</option>
|
</section>
|
||||||
<option value="Verdana">Verdana</option>
|
|
||||||
<option value="Georgia">Georgia</option>
|
<section class="card">
|
||||||
<option value="Times New Roman">Times New Roman</option>
|
<h2 class="card-title">Настройки текста</h2>
|
||||||
<option value="Courier New">Courier New</option>
|
|
||||||
<option value="Impact">Impact</option>
|
<div class="settings-grid">
|
||||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
<div class="setting-row">
|
||||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
<label class="setting-label">Размер</label>
|
||||||
</select>
|
<div class="setting-control">
|
||||||
</div>
|
<input type="range" id="fontSize" min="12" max="48" value="18" class="slider" />
|
||||||
|
<span class="value-display" id="fontSizeValue">18</span>
|
||||||
<div class="setting-row">
|
</div>
|
||||||
<label class="setting-label">Цвет</label>
|
</div>
|
||||||
<div class="setting-control">
|
|
||||||
<input type="color" id="textColor" value="#ffffff" class="color-input" />
|
<div class="setting-row">
|
||||||
<span class="color-value" id="textColorValue">#ffffff</span>
|
<label class="setting-label">Шрифт</label>
|
||||||
</div>
|
<select id="fontFamily" class="select-input">
|
||||||
</div>
|
<option value="Arial">Arial</option>
|
||||||
|
<option value="Verdana">Verdana</option>
|
||||||
<div class="setting-row">
|
<option value="Georgia">Georgia</option>
|
||||||
<label class="setting-label">Выравнивание</label>
|
<option value="Times New Roman">Times New Roman</option>
|
||||||
<div class="alignment-buttons">
|
<option value="Courier New">Courier New</option>
|
||||||
<button class="align-btn active" data-align="left">
|
<option value="Impact">Impact</option>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||||
<line x1="17" y1="10" x2="3" y2="10"></line>
|
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
</select>
|
||||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
</div>
|
||||||
<line x1="17" y1="18" x2="3" y2="18"></line>
|
|
||||||
</svg>
|
<div class="setting-row">
|
||||||
</button>
|
<label class="setting-label">Цвет</label>
|
||||||
<button class="align-btn" data-align="center">
|
<div class="setting-control">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<input type="color" id="textColor" value="#ffffff" class="color-input" />
|
||||||
<line x1="18" y1="10" x2="6" y2="10"></line>
|
<span class="color-value" id="textColorValue">#ffffff</span>
|
||||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
</div>
|
||||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
</div>
|
||||||
<line x1="18" y1="18" x2="6" y2="18"></line>
|
|
||||||
</svg>
|
<div class="setting-row">
|
||||||
</button>
|
<label class="setting-label">Выравнивание</label>
|
||||||
<button class="align-btn" data-align="right">
|
<div class="alignment-buttons">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<button class="align-btn active" data-align="left">
|
||||||
<line x1="21" y1="10" x2="7" y2="10"></line>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
<line x1="17" y1="10" x2="3" y2="10"></line>
|
||||||
<line x1="21" y1="14" x2="3" y2="14"></line>
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||||
<line x1="21" y1="18" x2="7" y2="18"></line>
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||||
</svg>
|
<line x1="17" y1="18" x2="3" y2="18"></line>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<button class="align-btn" data-align="center">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<div class="setting-row">
|
<line x1="18" y1="10" x2="6" y2="10"></line>
|
||||||
<label class="setting-label">Отступы</label>
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||||
<div class="setting-control">
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||||
<input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" />
|
<line x1="18" y1="18" x2="6" y2="18"></line>
|
||||||
<span class="value-display" id="sidePaddingValue">10</span>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<button class="align-btn" data-align="right">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<div class="setting-row">
|
<line x1="21" y1="10" x2="7" y2="10"></line>
|
||||||
<label class="setting-label">Смещение</label>
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||||
<div class="setting-control">
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
||||||
<input type="range" id="centerOffset" min="-50" max="50" value="0" class="slider" />
|
<line x1="21" y1="18" x2="7" y2="18"></line>
|
||||||
<span class="value-display" id="centerOffsetValue">0</span>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
<div class="setting-row">
|
||||||
|
<label class="setting-label">Отступы</label>
|
||||||
<!-- Right Column -->
|
<div class="setting-control">
|
||||||
<div class="right-column">
|
<input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" />
|
||||||
<section class="card">
|
<span class="value-display" id="sidePaddingValue">10</span>
|
||||||
<h2 class="card-title">Фоновое изображение</h2>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="crop-editor">
|
|
||||||
<div class="crop-canvas-container" id="cropCanvasContainer">
|
<div class="setting-row">
|
||||||
<canvas id="cropCanvas" class="crop-canvas"></canvas>
|
<label class="setting-label">Смещение</label>
|
||||||
<div class="crop-box" id="cropBox">
|
<div class="setting-control">
|
||||||
<div class="crop-handle nw"></div>
|
<input
|
||||||
<div class="crop-handle ne"></div>
|
type="range"
|
||||||
<div class="crop-handle sw"></div>
|
id="centerOffset"
|
||||||
<div class="crop-handle se"></div>
|
min="-50"
|
||||||
<div class="crop-handle n"></div>
|
max="50"
|
||||||
<div class="crop-handle s"></div>
|
value="0"
|
||||||
<div class="crop-handle w"></div>
|
class="slider"
|
||||||
<div class="crop-handle e"></div>
|
/>
|
||||||
</div>
|
<span class="value-display" id="centerOffsetValue">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="crop-controls">
|
</div>
|
||||||
<button id="uploadBgBtn" class="btn btn-secondary">
|
</section>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
</div>
|
||||||
<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>
|
<!-- Right Column -->
|
||||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
<div class="right-column">
|
||||||
</svg>
|
<section class="card">
|
||||||
Загрузить
|
<h2 class="card-title">Фоновое изображение</h2>
|
||||||
</button>
|
|
||||||
<input type="file" id="bgImageInput" accept="image/*" style="display: none" />
|
<div class="crop-editor">
|
||||||
|
<div class="crop-canvas-container" id="cropCanvasContainer">
|
||||||
<button id="resetCropBtn" class="btn btn-outline">
|
<canvas id="cropCanvas" class="crop-canvas"></canvas>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="crop-box" id="cropBox">
|
||||||
<polyline points="1 4 1 10 7 10"></polyline>
|
<div class="crop-handle nw"></div>
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
<div class="crop-handle ne"></div>
|
||||||
</svg>
|
<div class="crop-handle sw"></div>
|
||||||
Сбросить
|
<div class="crop-handle se"></div>
|
||||||
</button>
|
<div class="crop-handle n"></div>
|
||||||
</div>
|
<div class="crop-handle s"></div>
|
||||||
|
<div class="crop-handle w"></div>
|
||||||
<div class="settings-grid">
|
<div class="crop-handle e"></div>
|
||||||
<div class="setting-row">
|
</div>
|
||||||
<label class="setting-label">Яркость</label>
|
</div>
|
||||||
<div class="setting-control">
|
|
||||||
<input type="range" id="bgBrightness" min="50" max="150" value="100" class="slider" />
|
<div class="crop-controls">
|
||||||
<span class="value-display" id="bgBrightnessValue">100</span>
|
<button id="uploadBgBtn" class="btn btn-secondary">
|
||||||
</div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</div>
|
<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>
|
||||||
<div class="setting-row">
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
<label class="setting-label">Контраст</label>
|
</svg>
|
||||||
<div class="setting-control">
|
Загрузить
|
||||||
<input type="range" id="bgContrast" min="50" max="150" value="100" class="slider" />
|
</button>
|
||||||
<span class="value-display" id="bgContrastValue">100</span>
|
<input type="file" id="bgImageInput" accept="image/*" style="display: none" />
|
||||||
</div>
|
|
||||||
</div>
|
<button id="resetCropBtn" class="btn btn-outline">
|
||||||
</div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</div>
|
<polyline points="1 4 1 10 7 10"></polyline>
|
||||||
</section>
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||||||
|
</svg>
|
||||||
<section class="card">
|
Сбросить
|
||||||
<div class="card-header-row">
|
</button>
|
||||||
<h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2>
|
</div>
|
||||||
<div class="panel-nav">
|
|
||||||
<button id="prevPanel" class="nav-btn" disabled>
|
<div class="settings-grid">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="setting-row">
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
<label class="setting-label">Яркость</label>
|
||||||
</svg>
|
<div class="setting-control">
|
||||||
</button>
|
<input
|
||||||
<span id="panelIndicator" class="panel-indicator">0 / 0</span>
|
type="range"
|
||||||
<button id="nextPanel" class="nav-btn" disabled>
|
id="bgBrightness"
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
min="50"
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
max="150"
|
||||||
</svg>
|
value="100"
|
||||||
</button>
|
class="slider"
|
||||||
</div>
|
/>
|
||||||
</div>
|
<span class="value-display" id="bgBrightnessValue">100</span>
|
||||||
|
</div>
|
||||||
<div class="panel-viewer">
|
</div>
|
||||||
<div id="panelDisplay" class="panel-display">
|
|
||||||
<div class="empty-state">
|
<div class="setting-row">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<label class="setting-label">Контраст</label>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
<div class="setting-control">
|
||||||
<line x1="9" y1="9" x2="15" y2="9"></line>
|
<input
|
||||||
</svg>
|
type="range"
|
||||||
<p>Добавьте тексты для создания панелей</p>
|
id="bgContrast"
|
||||||
</div>
|
min="50"
|
||||||
</div>
|
max="150"
|
||||||
|
value="100"
|
||||||
<div class="panel-actions">
|
class="slider"
|
||||||
<button id="downloadCurrentBtn" class="btn btn-primary" disabled>
|
/>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="value-display" id="bgContrastValue">100</span>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
</div>
|
||||||
<polyline points="7 10 12 15 17 10"></polyline>
|
</div>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
</div>
|
||||||
</svg>
|
</div>
|
||||||
Скачать
|
</section>
|
||||||
</button>
|
|
||||||
<button id="downloadAllBtn" class="btn btn-outline" disabled>
|
<section class="card">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="card-header-row">
|
||||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
|
<h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2>
|
||||||
<polyline points="7 11 12 16 17 11"></polyline>
|
<div class="panel-nav">
|
||||||
<line x1="12" y1="16" x2="12" y2="4"></line>
|
<button id="prevPanel" class="nav-btn" disabled>
|
||||||
</svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
Скачать все
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<span id="panelIndicator" class="panel-indicator">0 / 0</span>
|
||||||
</section>
|
<button id="nextPanel" class="nav-btn" disabled>
|
||||||
</div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</div>
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
</div>
|
</svg>
|
||||||
|
</button>
|
||||||
<script src="script.js"></script>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
|
||||||
|
<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 { readFileSync } from "fs";
|
||||||
import { globSync } from "glob";
|
import { globSync } from "glob";
|
||||||
|
|
||||||
const includePatterns = ["src/**/*.svelte", "src/**/*.css"];
|
const includePatterns = ["src/**/*.svelte", "src/**/*.css"];
|
||||||
|
|
||||||
const files = globSync(includePatterns);
|
const files = globSync(includePatterns);
|
||||||
|
|
||||||
const defined = new Set();
|
const defined = new Set();
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
const usageLocations = [];
|
const usageLocations = [];
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const content = readFileSync(file, "utf-8");
|
const content = readFileSync(file, "utf-8");
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
|
|
||||||
const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g);
|
const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g);
|
||||||
for (const m of defMatches) {
|
for (const m of defMatches) {
|
||||||
defined.add(m[0]);
|
defined.add(m[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.forEach((line, idx) => {
|
lines.forEach((line, idx) => {
|
||||||
const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g);
|
const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g);
|
||||||
for (const match of varMatches) {
|
for (const match of varMatches) {
|
||||||
const varName = match[0].slice(4, -1).trim();
|
const varName = match[0].slice(4, -1).trim();
|
||||||
used.add(varName);
|
used.add(varName);
|
||||||
|
|
||||||
usageLocations.push({
|
usageLocations.push({
|
||||||
file,
|
file,
|
||||||
line: idx + 1,
|
line: idx + 1,
|
||||||
column: match.index + 1,
|
column: match.index + 1,
|
||||||
varName,
|
varName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let hasUndefined = false;
|
let hasUndefined = false;
|
||||||
const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName));
|
const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName));
|
||||||
|
|
||||||
undefinedErrors.forEach(({ file, line, column, varName }) => {
|
undefinedErrors.forEach(({ file, line, column, varName }) => {
|
||||||
console.error(`❌ ${file}:${line}:${column} — не определена CSS-переменная "${varName}"`);
|
console.error(`❌ ${file}:${line}:${column} — не определена CSS-переменная "${varName}"`);
|
||||||
hasUndefined = true;
|
hasUndefined = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const unused = [...defined].filter((varName) => !used.has(varName));
|
const unused = [...defined].filter((varName) => !used.has(varName));
|
||||||
if (unused.length > 0) {
|
if (unused.length > 0) {
|
||||||
console.warn("\n⚠️ Объявлены, но нигде не используются:");
|
console.warn("\n⚠️ Объявлены, но нигде не используются:");
|
||||||
unused.forEach((varName) => console.warn(` ${varName}`));
|
unused.forEach((varName) => console.warn(` ${varName}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUndefined) {
|
if (hasUndefined) {
|
||||||
console.error("\n❌ Найдены неопределённые переменные. Исправьте их.");
|
console.error("\n❌ Найдены неопределённые переменные. Исправьте их.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
if (unused.length === 0) {
|
if (unused.length === 0) {
|
||||||
console.log("\n✅ Все CSS-переменные определены и используются.");
|
console.log("\n✅ Все CSS-переменные определены и используются.");
|
||||||
} else {
|
} else {
|
||||||
console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения).");
|
console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения).");
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-79
@@ -1,79 +1,79 @@
|
|||||||
:root {
|
:root {
|
||||||
--brand-main: #86efac;
|
--brand-main: #86efac;
|
||||||
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
||||||
|
|
||||||
--action-lightness: 0.6;
|
--action-lightness: 0.6;
|
||||||
--action-chroma: 0.18;
|
--action-chroma: 0.18;
|
||||||
--hover-step: -0.08;
|
--hover-step: -0.08;
|
||||||
|
|
||||||
--danger-base: #ef4444;
|
--danger-base: #ef4444;
|
||||||
|
|
||||||
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
|
--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-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
|
||||||
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 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-control: oklch(from var(--brand-main) 0.85 0.04 h);
|
||||||
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 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: 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-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: 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);
|
--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-main: oklch(from var(--brand-main) 0.25 0.02 h);
|
||||||
--text-muted: oklch(from var(--brand-main) 0.45 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);
|
--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-main: oklch(from var(--brand-main) 0.9 0.04 h);
|
||||||
--border-strong: oklch(from var(--brand-main) 0.8 0.06 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: 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);
|
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||||
|
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--transition: all 0.3s ease;
|
--transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--action-lightness: 0.5;
|
--action-lightness: 0.5;
|
||||||
--action-chroma: 0.18;
|
--action-chroma: 0.18;
|
||||||
|
|
||||||
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
|
--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-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
|
||||||
--surface-elevated: oklch(from var(--brand-main) 0.2 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-control: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||||
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 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-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);
|
--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-main: oklch(from var(--brand-main) 0.94 0.01 h);
|
||||||
--text-muted: oklch(from var(--brand-main) 0.75 0.02 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);
|
/* --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-main: oklch(from var(--brand-main) 0.22 0.04 h);
|
||||||
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
|
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
|
||||||
|
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +1,108 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let active = $state(false);
|
let active = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="crop-canvas-container">
|
<div class="crop-canvas-container">
|
||||||
<canvas class="crop-canvas"></canvas>
|
<canvas class="crop-canvas"></canvas>
|
||||||
<div class="crop-box" class:active>
|
<div class="crop-box" class:active>
|
||||||
<div class="crop-handle nw"></div>
|
<div class="crop-handle nw"></div>
|
||||||
<div class="crop-handle ne"></div>
|
<div class="crop-handle ne"></div>
|
||||||
<div class="crop-handle sw"></div>
|
<div class="crop-handle sw"></div>
|
||||||
<div class="crop-handle se"></div>
|
<div class="crop-handle se"></div>
|
||||||
<div class="crop-handle n"></div>
|
<div class="crop-handle n"></div>
|
||||||
<div class="crop-handle s"></div>
|
<div class="crop-handle s"></div>
|
||||||
<div class="crop-handle w"></div>
|
<div class="crop-handle w"></div>
|
||||||
<div class="crop-handle e"></div>
|
<div class="crop-handle e"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.crop-canvas-container {
|
.crop-canvas-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 5;
|
aspect-ratio: 16 / 5;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-canvas {
|
.crop-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-box {
|
.crop-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 2px solid var(--action-primary);
|
border: 2px solid var(--action-primary);
|
||||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-box.active {
|
.crop-box.active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle {
|
.crop-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 2px solid var(--action-primary);
|
border: 2px solid var(--action-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.nw {
|
.crop-handle.nw {
|
||||||
top: -5px;
|
top: -5px;
|
||||||
left: -5px;
|
left: -5px;
|
||||||
cursor: nw-resize;
|
cursor: nw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.ne {
|
.crop-handle.ne {
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
cursor: ne-resize;
|
cursor: ne-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.sw {
|
.crop-handle.sw {
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
left: -5px;
|
left: -5px;
|
||||||
cursor: sw-resize;
|
cursor: sw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.se {
|
.crop-handle.se {
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
cursor: se-resize;
|
cursor: se-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.n {
|
.crop-handle.n {
|
||||||
top: -5px;
|
top: -5px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.s {
|
.crop-handle.s {
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
cursor: s-resize;
|
cursor: s-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.w {
|
.crop-handle.w {
|
||||||
left: -5px;
|
left: -5px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
cursor: w-resize;
|
cursor: w-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-handle.e {
|
.crop-handle.e {
|
||||||
right: -5px;
|
right: -5px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
cursor: e-resize;
|
cursor: e-resize;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from "$components/layout/Card.svelte";
|
import Card from "$components/layout/Card.svelte";
|
||||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
||||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
// import Pencil from "@lucide/svelte/icons/pencil";
|
||||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
||||||
// import Upload from "@lucide/svelte/icons/upload";
|
// import Upload from "@lucide/svelte/icons/upload";
|
||||||
import Pencil from "~icons/lucide/pencil";
|
import Pencil from "~icons/lucide/pencil";
|
||||||
import Reset from "~icons/lucide/rotate-ccw";
|
import Reset from "~icons/lucide/rotate-ccw";
|
||||||
import Upload from "~icons/lucide/upload";
|
import Upload from "~icons/lucide/upload";
|
||||||
import CropInline from "./CropInline.svelte";
|
import CropInline from "./CropInline.svelte";
|
||||||
|
|
||||||
let brightness = $state(100);
|
let brightness = $state(100);
|
||||||
let contrast = $state(100);
|
let contrast = $state(100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Фоновое изображение">
|
<Card title="Фоновое изображение">
|
||||||
<div class="crop-editor">
|
<div class="crop-editor">
|
||||||
<CropInline />
|
<CropInline />
|
||||||
|
|
||||||
<div class="crop-controls">
|
<div class="crop-controls">
|
||||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
||||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
||||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsGrid>
|
<SettingsGrid>
|
||||||
<SettingsRow label="Яркость">
|
<SettingsRow label="Яркость">
|
||||||
<RangeSlider bind:value={brightness} />
|
<RangeSlider bind:value={brightness} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Контраст">
|
<SettingsRow label="Контраст">
|
||||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
<RangeSlider bind:value={contrast} min={50} max={150} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGrid>
|
</SettingsGrid>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.crop-editor {
|
.crop-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.crop-controls {
|
.crop-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,86 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeState } from "$states/theme.svelte";
|
import { themeState } from "$states/theme.svelte";
|
||||||
import Moon from "~icons/lucide/moon";
|
import Moon from "~icons/lucide/moon";
|
||||||
import Sun from "~icons/lucide/sun";
|
import Sun from "~icons/lucide/sun";
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
themeState.toggle();
|
themeState.toggle();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1>Twitch Panels</h1>
|
<h1>Twitch Panels</h1>
|
||||||
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
||||||
<Sun class="sun-icon" />
|
<Sun class="sun-icon" />
|
||||||
<Moon class="moon-icon" />
|
<Moon class="moon-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header {
|
.header {
|
||||||
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
|
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
|
||||||
color: var(--text-action);
|
color: var(--text-action);
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle:hover {
|
.theme-toggle:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.theme-toggle svg) {
|
:global(.theme-toggle svg) {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.sun-icon) {
|
:global(.sun-icon) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.moon-icon) {
|
:global(.moon-icon) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-theme="dark"] .sun-icon) {
|
:global([data-theme="dark"] .sun-icon) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-theme="dark"] .moon-icon) {
|
:global([data-theme="dark"] .moon-icon) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,68 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string | Snippet;
|
title: string | Snippet;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
titleSnippet?: Snippet;
|
titleSnippet?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore block_empty -->
|
{#snippet emptySnippet()}{/snippet}
|
||||||
{#snippet emptySnippet()}{/snippet}
|
|
||||||
|
<section class="card">
|
||||||
<section class="card">
|
<div class="card-header-row">
|
||||||
<div class="card-header-row">
|
<h2 class="card-title">
|
||||||
<h2 class="card-title">
|
{#if typeof title === "string"}
|
||||||
{#if typeof title === "string"}
|
{title}
|
||||||
{title}
|
{:else}
|
||||||
{:else}
|
{@render title()}
|
||||||
{@render title()}
|
{/if}
|
||||||
{/if}
|
</h2>
|
||||||
</h2>
|
<div class="card-snippet">
|
||||||
<div class="card-snippet">
|
{@render titleSnippet()}
|
||||||
{@render titleSnippet()}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="card-body">
|
||||||
<div class="card-body">
|
{@render children()}
|
||||||
{@render children()}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
<style>
|
||||||
<style>
|
.card {
|
||||||
.card {
|
background: var(--surface-elevated);
|
||||||
background: var(--surface-elevated);
|
border: 1px solid var(--border-main);
|
||||||
border: 1px solid var(--border-main);
|
border-radius: var(--radius);
|
||||||
border-radius: var(--radius);
|
padding: 16px;
|
||||||
padding: 16px;
|
margin-bottom: 16px;
|
||||||
margin-bottom: 16px;
|
transition: var(--transition);
|
||||||
transition: var(--transition);
|
overflow: hidden;
|
||||||
overflow: hidden;
|
box-shadow: var(--shadow);
|
||||||
box-shadow: var(--shadow);
|
}
|
||||||
}
|
|
||||||
|
.card-title {
|
||||||
.card-title {
|
font-size: 15px;
|
||||||
font-size: 15px;
|
font-weight: 600;
|
||||||
font-weight: 600;
|
color: var(--text-main);
|
||||||
color: var(--text-main);
|
margin-bottom: 12px;
|
||||||
margin-bottom: 12px;
|
display: flex;
|
||||||
display: flex;
|
align-items: center;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
gap: 8px;
|
}
|
||||||
}
|
|
||||||
|
.card-header-row {
|
||||||
.card-header-row {
|
display: flex;
|
||||||
display: flex;
|
justify-content: space-between;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
align-items: center;
|
margin-bottom: 12px;
|
||||||
margin-bottom: 12px;
|
}
|
||||||
}
|
|
||||||
|
.card-snippet {
|
||||||
.card-snippet {
|
display: flex;
|
||||||
display: flex;
|
align-items: center;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
gap: 8px;
|
}
|
||||||
}
|
</style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageManager from "$components/image/ImageManager.svelte";
|
import ImageManager from "$components/image/ImageManager.svelte";
|
||||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel-bar">
|
<div class="panel-bar">
|
||||||
<ImageManager />
|
<ImageManager />
|
||||||
<PreviewManager />
|
<PreviewManager />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
noLabel?: boolean;
|
noLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { label, children, noLabel = false }: Props = $props();
|
let { label, children, noLabel = false }: Props = $props();
|
||||||
|
|
||||||
let tag = $derived(noLabel ? "div" : "label");
|
let tag = $derived(noLabel ? "div" : "label");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element this={tag} class="setting-row">
|
<svelte:element this={tag} class="setting-row">
|
||||||
<div class="setting-label">{label}</div>
|
<div class="setting-label">{label}</div>
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.setting-row {
|
.setting-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100px 1fr;
|
grid-template-columns: 100px 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-label {
|
.setting-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-control {
|
.setting-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import TextConfig from "$components/text/TextConfig.svelte";
|
import TextConfig from "$components/text/TextConfig.svelte";
|
||||||
import TextManager from "$components/text/TextManager.svelte";
|
import TextManager from "$components/text/TextManager.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-bar">
|
<div class="text-bar">
|
||||||
<TextManager />
|
<TextManager />
|
||||||
<TextConfig />
|
<TextConfig />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PANEL_SETTINGS } from "$lib/constants";
|
import { PANEL_SETTINGS } from "$lib/constants";
|
||||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||||
import { textConfigState } from "$states/textConfig.svelte";
|
import { textConfigState } from "$states/textConfig.svelte";
|
||||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
stage: Stage | undefined;
|
stage: Stage | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text, stage = $bindable() }: Props = $props();
|
let { text, stage = $bindable() }: Props = $props();
|
||||||
|
|
||||||
let x = $derived(textConfigState.paddingX);
|
let x = $derived(textConfigState.paddingX);
|
||||||
let y = $derived(10 + textConfigState.offsetY);
|
let y = $derived(10 + textConfigState.offsetY);
|
||||||
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
||||||
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
|
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Stage width={320} height={100} bind:this={stage}>
|
<Stage width={320} height={100} bind:this={stage}>
|
||||||
<Layer>
|
<Layer>
|
||||||
<Image image={imageConfigState.image}></Image>
|
<Image image={imageConfigState.image}></Image>
|
||||||
<Text
|
<Text
|
||||||
{text}
|
{text}
|
||||||
{x}
|
{x}
|
||||||
{y}
|
{y}
|
||||||
{width}
|
{width}
|
||||||
{height}
|
{height}
|
||||||
fontSize={textConfigState.fontSize}
|
fontSize={textConfigState.fontSize}
|
||||||
fill={textConfigState.color}
|
fill={textConfigState.color}
|
||||||
fontFamily={textConfigState.fontFamily}
|
fontFamily={textConfigState.fontFamily}
|
||||||
align={textConfigState.align}
|
align={textConfigState.align}
|
||||||
wrap="none"
|
wrap="none"
|
||||||
ellipsis={true}
|
ellipsis={true}
|
||||||
/>
|
/>
|
||||||
</Layer>
|
</Layer>
|
||||||
</Stage>
|
</Stage>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||||
import { textsState } from "$states/texts.svelte";
|
import { textsState } from "$states/texts.svelte";
|
||||||
|
|
||||||
import Preview from "./Preview.svelte";
|
import Preview from "./Preview.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="outside-display">
|
<div class="outside-display">
|
||||||
{#each textsState.texts as { text, id }, idx (id)}
|
{#each textsState.texts as { text, id }, idx (id)}
|
||||||
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
||||||
{:else}
|
{:else}
|
||||||
<p>No texts to preview</p>
|
<p>No texts to preview</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.outside-display {
|
.outside-display {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: -1000px;
|
top: -1000px;
|
||||||
left: -1000px;
|
left: -1000px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,40 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
||||||
import ChevronLeft from "~icons/lucide/chevron-left";
|
import ChevronLeft from "~icons/lucide/chevron-left";
|
||||||
import ChevronRight from "~icons/lucide/chevron-right";
|
import ChevronRight from "~icons/lucide/chevron-right";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
current: number;
|
current: number;
|
||||||
direction: SlideDirectionType;
|
direction: SlideDirectionType;
|
||||||
max: number;
|
max: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
||||||
|
|
||||||
let isFirst = $derived(current === 0);
|
let isFirst = $derived(current === 0);
|
||||||
let isLast = $derived(current === max - 1);
|
let isLast = $derived(current === max - 1);
|
||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
if (current > 0) current--;
|
if (current > 0) current--;
|
||||||
direction = SlideDirection.PREV;
|
direction = SlideDirection.PREV;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function next() {
|
async function next() {
|
||||||
if (current < max - 1) current++;
|
if (current < max - 1) current++;
|
||||||
direction = SlideDirection.NEXT;
|
direction = SlideDirection.NEXT;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button icon={ChevronLeft} ariaLabel="Previous slide" type="mini" onclick={prev} disabled={isFirst} />
|
<Button
|
||||||
<span class="panel-indicator">{current + 1} / {max}</span>
|
icon={ChevronLeft}
|
||||||
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
ariaLabel="Previous slide"
|
||||||
|
type="mini"
|
||||||
<style>
|
onclick={prev}
|
||||||
.panel-indicator {
|
disabled={isFirst}
|
||||||
font-size: 13px;
|
/>
|
||||||
font-weight: 500;
|
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||||
min-width: 50px;
|
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
||||||
text-align: center;
|
|
||||||
}
|
<style>
|
||||||
</style>
|
.panel-indicator {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,136 +1,144 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from "$components/layout/Card.svelte";
|
import Card from "$components/layout/Card.svelte";
|
||||||
import Badge from "$components/ui/Badge.svelte";
|
import Badge from "$components/ui/Badge.svelte";
|
||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
||||||
import { downloadService, type DownloadItem } from "$services/downloadService";
|
import { downloadService, type DownloadItem } from "$services/downloadService";
|
||||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||||
import { textsState } from "$states/texts.svelte";
|
import { textsState } from "$states/texts.svelte";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import Download from "~icons/lucide/download";
|
import Download from "~icons/lucide/download";
|
||||||
import StickyNote from "~icons/lucide/sticky-note";
|
import StickyNote from "~icons/lucide/sticky-note";
|
||||||
import Preview from "./Preview.svelte";
|
import Preview from "./Preview.svelte";
|
||||||
import PreviewAll from "./PreviewAll.svelte";
|
import PreviewAll from "./PreviewAll.svelte";
|
||||||
import PreviewControls from "./PreviewControls.svelte";
|
import PreviewControls from "./PreviewControls.svelte";
|
||||||
|
|
||||||
let current: number = $state(0);
|
let current: number = $state(0);
|
||||||
let direction: SlideDirectionType = $state("next");
|
let direction: SlideDirectionType = $state("next");
|
||||||
|
|
||||||
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
|
let xDirection = $derived(
|
||||||
|
direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH,
|
||||||
$effect(() => {
|
);
|
||||||
if (textsState.texts.length === 0) {
|
|
||||||
current = 0;
|
$effect(() => {
|
||||||
} else {
|
if (textsState.texts.length === 0) {
|
||||||
if (current > textsState.texts.length - 1) {
|
current = 0;
|
||||||
current = textsState.texts.length - 1;
|
} 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,
|
function downloadAll() {
|
||||||
stage: konvaAllStagesState[idx]?.node,
|
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
|
||||||
}));
|
filename: text.text,
|
||||||
|
stage: konvaAllStagesState[idx]?.node,
|
||||||
downloadService.downloadAll(downloadItems);
|
}));
|
||||||
}
|
|
||||||
|
downloadService.downloadAll(downloadItems);
|
||||||
function downloadCurrent() {
|
}
|
||||||
let node = konvaStageState.stage?.node;
|
|
||||||
if (node) {
|
function downloadCurrent() {
|
||||||
downloadService.downloadPanel(node, textsState.texts[current].text);
|
let node = konvaStageState.stage?.node;
|
||||||
}
|
if (node) {
|
||||||
}
|
downloadService.downloadPanel(node, textsState.texts[current].text);
|
||||||
</script>
|
}
|
||||||
|
}
|
||||||
{#snippet panelTitle()}
|
</script>
|
||||||
Панели <Badge text={textsState.texts.length} />
|
|
||||||
{/snippet}
|
{#snippet panelTitle()}
|
||||||
|
Панели <Badge text={textsState.texts.length} />
|
||||||
{#snippet panelControls()}
|
{/snippet}
|
||||||
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
|
||||||
{/snippet}
|
{#snippet panelControls()}
|
||||||
|
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||||
<Card title={panelTitle} titleSnippet={panelControls}>
|
{/snippet}
|
||||||
<div class="panel-viewer">
|
|
||||||
<div class="panel-display">
|
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||||
{#if textsState.texts.length}
|
<div class="panel-viewer">
|
||||||
{#key current}
|
<div class="panel-display">
|
||||||
{@const text = textsState?.texts[current]?.text}
|
{#if textsState.texts.length}
|
||||||
<div
|
{#key current}
|
||||||
class="konva-wrapper"
|
{@const text = textsState?.texts[current]?.text}
|
||||||
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
<div
|
||||||
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
class="konva-wrapper"
|
||||||
>
|
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
||||||
<Preview {text} bind:stage={konvaStageState.stage} />
|
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
||||||
</div>
|
>
|
||||||
{/key}
|
<Preview {text} bind:stage={konvaStageState.stage} />
|
||||||
{:else}
|
</div>
|
||||||
<div class="empty-state" aria-label="Empty texts info">
|
{/key}
|
||||||
<StickyNote />
|
{:else}
|
||||||
<p>Добавьте тексты для создания панелей</p>
|
<div class="empty-state" aria-label="Empty texts info">
|
||||||
</div>
|
<StickyNote />
|
||||||
{/if}
|
<p>Добавьте тексты для создания панелей</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="panel-actions">
|
</div>
|
||||||
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
|
||||||
<Button label="Скачать" ariaLabel="Download current" type="secondary" icon={Download} onclick={downloadCurrent} />
|
<div class="panel-actions">
|
||||||
</div>
|
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
||||||
</div>
|
<Button
|
||||||
</Card>
|
label="Скачать"
|
||||||
<PreviewAll />
|
ariaLabel="Download current"
|
||||||
|
type="secondary"
|
||||||
<style>
|
icon={Download}
|
||||||
.panel-viewer {
|
onclick={downloadCurrent}
|
||||||
display: flex;
|
/>
|
||||||
flex-direction: column;
|
</div>
|
||||||
gap: 12px;
|
</div>
|
||||||
}
|
</Card>
|
||||||
|
<PreviewAll />
|
||||||
.panel-display {
|
|
||||||
background: var(--surface-subtle);
|
<style>
|
||||||
border-radius: var(--radius);
|
.panel-viewer {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: 12px;
|
||||||
align-items: center;
|
}
|
||||||
min-height: 150px;
|
|
||||||
position: relative;
|
.panel-display {
|
||||||
}
|
background: var(--surface-subtle);
|
||||||
|
border-radius: var(--radius);
|
||||||
.panel-actions {
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
}
|
align-items: center;
|
||||||
|
min-height: 150px;
|
||||||
.empty-state {
|
position: relative;
|
||||||
text-align: center;
|
}
|
||||||
padding: 32px 16px;
|
|
||||||
}
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
.empty-state :global(svg) {
|
gap: 8px;
|
||||||
width: 48px;
|
}
|
||||||
height: 48px;
|
|
||||||
margin: 0 auto 12px;
|
.empty-state {
|
||||||
opacity: 0.5;
|
text-align: center;
|
||||||
}
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
.empty-state p {
|
|
||||||
font-size: 14px;
|
.empty-state :global(svg) {
|
||||||
}
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
.konva-wrapper {
|
margin: 0 auto 12px;
|
||||||
position: absolute;
|
opacity: 0.5;
|
||||||
top: 0;
|
}
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
.empty-state p {
|
||||||
height: 100%;
|
font-size: 14px;
|
||||||
display: flex;
|
}
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
.konva-wrapper {
|
||||||
}
|
position: absolute;
|
||||||
</style>
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from "$components/layout/Card.svelte";
|
import Card from "$components/layout/Card.svelte";
|
||||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||||
import Alignment from "$components/ui/Alignment.svelte";
|
import Alignment from "$components/ui/Alignment.svelte";
|
||||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||||
import { textConfigState } from "$states/textConfig.svelte";
|
import { textConfigState } from "$states/textConfig.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Настройки текста">
|
<Card title="Настройки текста">
|
||||||
<SettingsGrid>
|
<SettingsGrid>
|
||||||
<SettingsRow label="Размер">
|
<SettingsRow label="Размер">
|
||||||
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
|
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Шрифт">
|
<SettingsRow label="Шрифт">
|
||||||
<SelectFont bind:value={textConfigState.fontFamily} />
|
<SelectFont bind:value={textConfigState.fontFamily} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Цвет">
|
<SettingsRow label="Цвет">
|
||||||
<ColorPicker bind:value={textConfigState.color} />
|
<ColorPicker bind:value={textConfigState.color} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Выравнивание" noLabel={true}>
|
<SettingsRow label="Выравнивание" noLabel={true}>
|
||||||
<Alignment bind:align={textConfigState.align} />
|
<Alignment bind:align={textConfigState.align} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Отступы">
|
<SettingsRow label="Отступы">
|
||||||
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
|
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Смещение">
|
<SettingsRow label="Смещение">
|
||||||
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
|
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGrid>
|
</SettingsGrid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import { TRANSITION_DURATION } from "$lib/constants";
|
import { TRANSITION_DURATION } from "$lib/constants";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import Cross from "~icons/lucide/x";
|
import Cross from "~icons/lucide/x";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
id: number;
|
id: number;
|
||||||
ondelete: (id: number) => void;
|
ondelete: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text = $bindable(), id, ondelete }: Props = $props();
|
let { text = $bindable(), id, ondelete }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
||||||
<input type="text" bind:value={text} />
|
<input type="text" bind:value={text} />
|
||||||
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.text-item {
|
.text-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--border-main);
|
border: 1px solid var(--border-main);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-item:hover {
|
.text-item:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-item input {
|
.text-item input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 1px solid var(--border-main);
|
border: 1px solid var(--border-main);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-item input:focus {
|
.text-item input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
onenter: () => void;
|
onenter: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
||||||
|
|
||||||
function handleKeyboard(event: KeyboardEvent) {
|
function handleKeyboard(event: KeyboardEvent) {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
onenter();
|
onenter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
class="text-input"
|
class="text-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Введите текст..."
|
placeholder="Введите текст..."
|
||||||
onkeydown={handleKeyboard}
|
onkeydown={handleKeyboard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.text-input {
|
.text-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--border-main);
|
border: 1px solid var(--border-main);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input:focus {
|
.text-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input::placeholder {
|
.text-input::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from "$components/layout/Card.svelte";
|
import Card from "$components/layout/Card.svelte";
|
||||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import { textsState } from "$states/texts.svelte";
|
import { textsState } from "$states/texts.svelte";
|
||||||
import Plus from "~icons/lucide/plus";
|
import Plus from "~icons/lucide/plus";
|
||||||
import TextInlineEdit from "./TextInlineEdit.svelte";
|
import TextInlineEdit from "./TextInlineEdit.svelte";
|
||||||
import TextInput from "./TextInput.svelte";
|
import TextInput from "./TextInput.svelte";
|
||||||
|
|
||||||
let text: string = $state("");
|
let text: string = $state("");
|
||||||
|
|
||||||
function addText() {
|
function addText() {
|
||||||
textsState.addText(text);
|
textsState.addText(text);
|
||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteText(id: number) {
|
function deleteText(id: number) {
|
||||||
textsState.removeText(id);
|
textsState.removeText(id);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Тексты панелей">
|
<Card title="Тексты панелей">
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
||||||
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<ul class="texts-list">
|
<ul class="texts-list">
|
||||||
{#each textsState.texts as { text, id } (id)}
|
{#each textsState.texts as { text, id } (id)}
|
||||||
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
|
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.texts-list {
|
.texts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { TextAlign, type TextAlignType } from "$lib/constants";
|
import { TextAlign, type TextAlignType } from "$lib/constants";
|
||||||
import TextAlignCenter from "~icons/lucide/text-align-center";
|
import TextAlignCenter from "~icons/lucide/text-align-center";
|
||||||
import TextAlignEnd from "~icons/lucide/text-align-end";
|
import TextAlignEnd from "~icons/lucide/text-align-end";
|
||||||
import TextAlignStart from "~icons/lucide/text-align-start";
|
import TextAlignStart from "~icons/lucide/text-align-start";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
align: TextAlignType;
|
align: TextAlignType;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="alignment-group">
|
<div class="alignment-group">
|
||||||
<label class="alignment-item">
|
<label class="alignment-item">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="alignment"
|
name="alignment"
|
||||||
value={TextAlign.LEFT}
|
value={TextAlign.LEFT}
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
bind:group={align}
|
bind:group={align}
|
||||||
aria-label={`Set ${TextAlign.LEFT}`}
|
aria-label={`Set ${TextAlign.LEFT}`}
|
||||||
/>
|
/>
|
||||||
<div class="alignment-button"><TextAlignStart /></div>
|
<div class="alignment-button"><TextAlignStart /></div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="alignment-item">
|
<label class="alignment-item">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="alignment"
|
name="alignment"
|
||||||
value={TextAlign.CENTER}
|
value={TextAlign.CENTER}
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
bind:group={align}
|
bind:group={align}
|
||||||
aria-label={`Set ${TextAlign.CENTER}`}
|
aria-label={`Set ${TextAlign.CENTER}`}
|
||||||
/>
|
/>
|
||||||
<div class="alignment-button"><TextAlignCenter /></div>
|
<div class="alignment-button"><TextAlignCenter /></div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="alignment-item">
|
<label class="alignment-item">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="alignment"
|
name="alignment"
|
||||||
value={TextAlign.RIGHT}
|
value={TextAlign.RIGHT}
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
bind:group={align}
|
bind:group={align}
|
||||||
aria-label={`Set ${TextAlign.RIGHT}`}
|
aria-label={`Set ${TextAlign.RIGHT}`}
|
||||||
/>
|
/>
|
||||||
<div class="alignment-button"><TextAlignEnd /></div>
|
<div class="alignment-button"><TextAlignEnd /></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.alignment-group {
|
.alignment-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alignment-button {
|
.alignment-button {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alignment-button:hover {
|
.alignment-button:hover {
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alignment-item {
|
.alignment-item {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
& input {
|
& input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
&:first-child .alignment-button {
|
&:first-child .alignment-button {
|
||||||
border-top-left-radius: var(--radius);
|
border-top-left-radius: var(--radius);
|
||||||
border-bottom-left-radius: var(--radius);
|
border-bottom-left-radius: var(--radius);
|
||||||
}
|
}
|
||||||
&:last-child .alignment-button {
|
&:last-child .alignment-button {
|
||||||
border-top-right-radius: var(--radius);
|
border-top-right-radius: var(--radius);
|
||||||
border-bottom-right-radius: var(--radius);
|
border-bottom-right-radius: var(--radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alignment-item input:checked + .alignment-button {
|
.alignment-item input:checked + .alignment-button {
|
||||||
background: var(--action-primary);
|
background: var(--action-primary);
|
||||||
color: var(--text-action);
|
color: var(--text-action);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string | number;
|
text: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text }: Props = $props();
|
let { text }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="badge">{text}</span>
|
<span class="badge">{text}</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
background: var(--action-primary);
|
background: var(--action-primary);
|
||||||
color: var(--text-action);
|
color: var(--text-action);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+115
-115
@@ -1,115 +1,115 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
import type { MouseEventHandler } from "svelte/elements";
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon: Component;
|
icon: Component;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||||
extra?: "grow";
|
extra?: "grow";
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
onclick = () => {},
|
onclick = () => {},
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label = "",
|
label = "",
|
||||||
type = "primary",
|
type = "primary",
|
||||||
extra,
|
extra,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
class:btn-primary={type === "primary"}
|
class:btn-primary={type === "primary"}
|
||||||
class:btn-secondary={type === "secondary"}
|
class:btn-secondary={type === "secondary"}
|
||||||
class:btn-outline={type === "outline"}
|
class:btn-outline={type === "outline"}
|
||||||
class:btn-danger={type === "danger"}
|
class:btn-danger={type === "danger"}
|
||||||
class:btn-mini={type === "mini"}
|
class:btn-mini={type === "mini"}
|
||||||
class:grow={extra === "grow"}
|
class:grow={extra === "grow"}
|
||||||
{disabled}
|
{disabled}
|
||||||
{onclick}
|
{onclick}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.btn {
|
.btn {
|
||||||
--btn-main: var(--action-primary);
|
--btn-main: var(--action-primary);
|
||||||
--btn-hover: var(--action-primary-hover);
|
--btn-hover: var(--action-primary-hover);
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background-color: var(--btn-main);
|
background-color: var(--btn-main);
|
||||||
color: oklch(from var(--btn-main) 99% 0.05 h);
|
color: var(--text-action);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn :global(svg) {
|
.btn :global(svg) {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover:not(:disabled) {
|
.btn:hover:not(:disabled) {
|
||||||
background-color: var(--btn-hover);
|
background-color: var(--btn-hover);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
--btn-main: var(--action-secondary);
|
--btn-main: var(--action-secondary);
|
||||||
--btn-hover: var(--action-secondary-hover);
|
--btn-hover: var(--action-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:hover:not(:disabled) {
|
.btn-outline:hover:not(:disabled) {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
--btn-main: var(--danger-base);
|
--btn-main: var(--danger-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-mini {
|
.btn-mini {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--border-main);
|
border: 1px solid var(--border-main);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-mini:hover:not(:disabled) {
|
.btn-mini:hover:not(:disabled) {
|
||||||
border-color: var(--border-main);
|
border-color: var(--border-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grow {
|
.grow {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HexColor } from "$lib/types";
|
import type { HexColor } from "$lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: HexColor;
|
value: HexColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { value = $bindable() }: Props = $props();
|
let { value = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input type="color" bind:value class="color-input" />
|
<input type="color" bind:value class="color-input" />
|
||||||
<span class="color-value">{value}</span>
|
<span class="color-value">{value}</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.color-input {
|
.color-input {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 1px solid var(--border-main);
|
border: 1px solid var(--border-main);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
padding: 0px 8px;
|
padding: 0px 8px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-input:hover {
|
.color-input:hover {
|
||||||
border-color: var(--action-primary);
|
border-color: var(--action-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-value {
|
.color-value {
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,68 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ChangeEventHandler } from "svelte/elements";
|
import type { ChangeEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: number;
|
value: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
|
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
||||||
<span class="value-display">{value}</span>
|
<span class="value-display">{value}</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.slider {
|
.slider {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--surface-control);
|
background: var(--surface-control);
|
||||||
outline: none;
|
outline: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb {
|
.slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--action-primary);
|
background: var(--action-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb:hover {
|
.slider::-webkit-slider-thumb:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
.slider::-moz-range-thumb {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--action-primary);
|
background: var(--action-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-moz-range-thumb:hover {
|
.slider::-moz-range-thumb:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-display {
|
.value-display {
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { value = $bindable() }: Props = $props();
|
let { value = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<select class="select-input" bind:value>
|
<select class="select-input" bind:value>
|
||||||
<option value="Arial">Arial</option>
|
<option value="Arial">Arial</option>
|
||||||
<option value="Verdana">Verdana</option>
|
<option value="Verdana">Verdana</option>
|
||||||
<option value="Georgia">Georgia</option>
|
<option value="Georgia">Georgia</option>
|
||||||
<option value="Times New Roman">Times New Roman</option>
|
<option value="Times New Roman">Times New Roman</option>
|
||||||
<option value="Courier New">Courier New</option>
|
<option value="Courier New">Courier New</option>
|
||||||
<option value="Impact">Impact</option>
|
<option value="Impact">Impact</option>
|
||||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.select-input {
|
.select-input {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--border-main);
|
border: 1px solid var(--border-main);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface-main);
|
background: var(--surface-main);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-input:focus {
|
.select-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+77
-77
@@ -1,77 +1,77 @@
|
|||||||
// Application Constants
|
// Application Constants
|
||||||
|
|
||||||
// ===== PANEL SETTINGS =====
|
// ===== PANEL SETTINGS =====
|
||||||
export const PANEL_SETTINGS = {
|
export const PANEL_SETTINGS = {
|
||||||
PANEL_WIDTH: 320,
|
PANEL_WIDTH: 320,
|
||||||
PANEL_HEIGHT_DEFAULT: 100,
|
PANEL_HEIGHT_DEFAULT: 100,
|
||||||
PANEL_HEIGHT_MAX: 200,
|
PANEL_HEIGHT_MAX: 200,
|
||||||
|
|
||||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ===== TYPOGRAPHY =====
|
// ===== TYPOGRAPHY =====
|
||||||
export const TYPOGRAPHY = {
|
export const TYPOGRAPHY = {
|
||||||
FONT_FAMILY_DEFAULT: "Arial",
|
FONT_FAMILY_DEFAULT: "Arial",
|
||||||
FONT_FAMILIES: [
|
FONT_FAMILIES: [
|
||||||
"Arial",
|
"Arial",
|
||||||
"Verdana",
|
"Verdana",
|
||||||
"Georgia",
|
"Georgia",
|
||||||
"Times New Roman",
|
"Times New Roman",
|
||||||
"Courier New",
|
"Courier New",
|
||||||
"Impact",
|
"Impact",
|
||||||
"Comic Sans MS",
|
"Comic Sans MS",
|
||||||
"Trebuchet MS",
|
"Trebuchet MS",
|
||||||
],
|
],
|
||||||
|
|
||||||
// Font sizes for range inputs
|
// Font sizes for range inputs
|
||||||
FONT_SIZE_MIN: 10,
|
FONT_SIZE_MIN: 10,
|
||||||
FONT_SIZE_MAX: 72,
|
FONT_SIZE_MAX: 72,
|
||||||
FONT_SIZE_DEFAULT: 32,
|
FONT_SIZE_DEFAULT: 32,
|
||||||
|
|
||||||
// Text limits
|
// Text limits
|
||||||
MAX_TEXT_LENGTH: 100,
|
MAX_TEXT_LENGTH: 100,
|
||||||
|
|
||||||
// Padding for range inputs
|
// Padding for range inputs
|
||||||
PADDING_X_DEFAULT: 10,
|
PADDING_X_DEFAULT: 10,
|
||||||
PADDING_X_MAX: 100,
|
PADDING_X_MAX: 100,
|
||||||
|
|
||||||
// Vertical offset for range inputs
|
// Vertical offset for range inputs
|
||||||
VERTICAL_OFFSET_MAX: 100,
|
VERTICAL_OFFSET_MAX: 100,
|
||||||
VERTICAL_OFFSET_MIN: -100,
|
VERTICAL_OFFSET_MIN: -100,
|
||||||
|
|
||||||
// Colors
|
// Colors
|
||||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ===== IMAGE SETTINGS =====
|
// ===== IMAGE SETTINGS =====
|
||||||
export const IMAGE_SETTINGS = {
|
export const IMAGE_SETTINGS = {
|
||||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||||
|
|
||||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SlideDirection = {
|
export const SlideDirection = {
|
||||||
NEXT: "next",
|
NEXT: "next",
|
||||||
PREV: "prev",
|
PREV: "prev",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
||||||
|
|
||||||
export const TextAlign = {
|
export const TextAlign = {
|
||||||
LEFT: "left",
|
LEFT: "left",
|
||||||
CENTER: "center",
|
CENTER: "center",
|
||||||
RIGHT: "right",
|
RIGHT: "right",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
||||||
|
|
||||||
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
||||||
|
|
||||||
export const Theme = {
|
export const Theme = {
|
||||||
DARK: "dark",
|
DARK: "dark",
|
||||||
LIGHT: "light",
|
LIGHT: "light",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||||
|
|
||||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||||
|
|||||||
+37
-37
@@ -1,37 +1,37 @@
|
|||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public code: string,
|
public code: string,
|
||||||
public details?: unknown,
|
public details?: unknown,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "AppError";
|
this.name = "AppError";
|
||||||
if (details) this.details = details;
|
if (details) this.details = details;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageError extends AppError {
|
export class ImageError extends AppError {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message, "IMAGE_ERROR");
|
super(message, "IMAGE_ERROR");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TextError extends AppError {
|
export class TextError extends AppError {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message, "TEXT_ERROR");
|
super(message, "TEXT_ERROR");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CanvasError extends AppError {
|
export class CanvasError extends AppError {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message, "CANVAS_ERROR");
|
super(message, "CANVAS_ERROR");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StorageError extends AppError {
|
export class StorageError extends AppError {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message, "STORAGE_ERROR");
|
super(message, "STORAGE_ERROR");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
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";
|
import { AppError } from "$lib/error.types";
|
||||||
|
|
||||||
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||||
if (error instanceof AppError || error instanceof Error) {
|
if (error instanceof AppError || error instanceof Error) {
|
||||||
return `${defaultMessage}: ${error.message}`;
|
return `${defaultMessage}: ${error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
return `${defaultMessage}: ${error}`;
|
return `${defaultMessage}: ${error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createError(message: string, code: string, details?: unknown): AppError {
|
export function createError(message: string, code: string, details?: unknown): AppError {
|
||||||
return new AppError(message, code, details);
|
return new AppError(message, code, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logError(error: unknown, context?: string): void {
|
export function logError(error: unknown, context?: string): void {
|
||||||
console.error("Error occurred:", {
|
console.error("Error occurred:", {
|
||||||
error,
|
error,
|
||||||
context,
|
context,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-35
@@ -1,35 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||||
import { PANEL_SETTINGS } from "$lib/constants";
|
import { PANEL_SETTINGS } from "$lib/constants";
|
||||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||||
import { themeState } from "$states/theme.svelte";
|
import { themeState } from "$states/theme.svelte";
|
||||||
import { onMount, type Snippet } from "svelte";
|
import { onMount, type Snippet } from "svelte";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const newTheme = themeState.current;
|
const newTheme = themeState.current;
|
||||||
document.documentElement.setAttribute("data-theme", newTheme);
|
document.documentElement.setAttribute("data-theme", newTheme);
|
||||||
});
|
});
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|||||||
+23
-23
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||||
import TextBar from "$components/layout/TextBar.svelte";
|
import TextBar from "$components/layout/TextBar.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="main-grid">
|
<div class="main-grid">
|
||||||
<TextBar />
|
<TextBar />
|
||||||
<PanelBar />
|
<PanelBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.main-grid {
|
.main-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.main-grid {
|
.main-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,83 +1,83 @@
|
|||||||
import { ImageError } from "$lib/error.types";
|
import { ImageError } from "$lib/error.types";
|
||||||
import { formatError, logError } from "$lib/utils/errorUtils";
|
import { formatError, logError } from "$lib/utils/errorUtils";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { Stage } from "konva/lib/Stage";
|
import { Stage } from "konva/lib/Stage";
|
||||||
|
|
||||||
export type DownloadResult =
|
export type DownloadResult =
|
||||||
| {
|
| {
|
||||||
success: true;
|
success: true;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
success: false;
|
success: false;
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DownloadItem {
|
export interface DownloadItem {
|
||||||
filename: string;
|
filename: string;
|
||||||
stage: Stage;
|
stage: Stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DownloadService {
|
export class DownloadService {
|
||||||
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
||||||
try {
|
try {
|
||||||
const blob = await this.stageToBlob(konvaStage);
|
const blob = await this.stageToBlob(konvaStage);
|
||||||
|
|
||||||
const filename = `${label}.png`;
|
const filename = `${label}.png`;
|
||||||
saveAs(blob, filename);
|
saveAs(blob, filename);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "Ошибка сохранения панели");
|
logError(error, "Ошибка сохранения панели");
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: formatError(error, "Ошибка сохранения панели"),
|
error: formatError(error, "Ошибка сохранения панели"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
||||||
try {
|
try {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
for (let panel of panels) {
|
for (const panel of panels) {
|
||||||
const blob = await this.stageToBlob(panel.stage);
|
const blob = await this.stageToBlob(panel.stage);
|
||||||
zip.file(`${panel.filename}.png`, blob);
|
zip.file(`${panel.filename}.png`, blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||||
saveAs(zipBlob, "panels.zip");
|
saveAs(zipBlob, "panels.zip");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "Ошибка сохранения архива");
|
logError(error, "Ошибка сохранения архива");
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: formatError(error, "Ошибка сохранения архива"),
|
error: formatError(error, "Ошибка сохранения архива"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
||||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
konvaStage.toBlob({
|
konvaStage.toBlob({
|
||||||
callback: (blob: Blob | null) => {
|
callback: (blob: Blob | null) => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
} else {
|
} else {
|
||||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadService = new DownloadService();
|
export const downloadService = new DownloadService();
|
||||||
|
|||||||
@@ -1,98 +1,98 @@
|
|||||||
export type ImageConfig = {
|
export type ImageConfig = {
|
||||||
image: HTMLImageElement | undefined;
|
image: HTMLImageElement | undefined;
|
||||||
imageLink: string;
|
imageLink: string;
|
||||||
imageReady: boolean;
|
imageReady: boolean;
|
||||||
cropLeft: number;
|
cropLeft: number;
|
||||||
cropTop: number;
|
cropTop: number;
|
||||||
cropRight: number;
|
cropRight: number;
|
||||||
cropBottom: number;
|
cropBottom: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ImageConfigState {
|
export class ImageConfigState {
|
||||||
image = $state<HTMLImageElement | undefined>(undefined);
|
image = $state<HTMLImageElement | undefined>(undefined);
|
||||||
imageLink = $state("");
|
imageLink = $state("");
|
||||||
imageReady = $state(false);
|
imageReady = $state(false);
|
||||||
cropLeft = $state(0);
|
cropLeft = $state(0);
|
||||||
cropTop = $state(0);
|
cropTop = $state(0);
|
||||||
cropRight = $state(0);
|
cropRight = $state(0);
|
||||||
cropBottom = $state(0);
|
cropBottom = $state(0);
|
||||||
|
|
||||||
#currentAbortController: AbortController | null = null;
|
#currentAbortController: AbortController | null = null;
|
||||||
|
|
||||||
private cleanup() {
|
private cleanup() {
|
||||||
if (this.#currentAbortController) {
|
if (this.#currentAbortController) {
|
||||||
this.#currentAbortController.abort();
|
this.#currentAbortController.abort();
|
||||||
this.#currentAbortController = null;
|
this.#currentAbortController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.image) {
|
if (this.image) {
|
||||||
this.image.onload = null;
|
this.image.onload = null;
|
||||||
this.image.onerror = null;
|
this.image.onerror = null;
|
||||||
this.image.src = "";
|
this.image.src = "";
|
||||||
this.image = undefined;
|
this.image = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadImageByLink(link: string): Promise<void> {
|
async uploadImageByLink(link: string): Promise<void> {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
|
|
||||||
this.imageReady = false;
|
this.imageReady = false;
|
||||||
this.imageLink = link;
|
this.imageLink = link;
|
||||||
|
|
||||||
this.#currentAbortController = new AbortController();
|
this.#currentAbortController = new AbortController();
|
||||||
const { signal } = this.#currentAbortController;
|
const { signal } = this.#currentAbortController;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous";
|
img.crossOrigin = "anonymous";
|
||||||
|
|
||||||
const onFinished = () => {
|
const onFinished = () => {
|
||||||
img.onload = null;
|
img.onload = null;
|
||||||
img.onerror = null;
|
img.onerror = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
onFinished();
|
onFinished();
|
||||||
this.image = img;
|
this.image = img;
|
||||||
this.imageReady = true;
|
this.imageReady = true;
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
onFinished();
|
onFinished();
|
||||||
this.imageReady = false;
|
this.imageReady = false;
|
||||||
reject(new Error(`Failed to load image: ${link}`));
|
reject(new Error(`Failed to load image: ${link}`));
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener(
|
signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
onFinished();
|
onFinished();
|
||||||
img.src = "";
|
img.src = "";
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
img.src = link;
|
img.src = link;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
this.imageLink = "";
|
this.imageLink = "";
|
||||||
this.imageReady = false;
|
this.imageReady = false;
|
||||||
this.cropLeft = 0;
|
this.cropLeft = 0;
|
||||||
this.cropTop = 0;
|
this.cropTop = 0;
|
||||||
this.cropRight = 0;
|
this.cropRight = 0;
|
||||||
this.cropBottom = 0;
|
this.cropBottom = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageConfigState = new ImageConfigState();
|
export const imageConfigState = new ImageConfigState();
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import type { Stage } from "svelte-konva";
|
import type { Stage } from "svelte-konva";
|
||||||
|
|
||||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { Stage } from "svelte-konva";
|
import type { Stage } from "svelte-konva";
|
||||||
|
|
||||||
function createState() {
|
function createState() {
|
||||||
let stage: Stage | undefined = $state(undefined);
|
let stage: Stage | undefined = $state(undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get stage(): Stage | undefined {
|
get stage(): Stage | undefined {
|
||||||
return stage;
|
return stage;
|
||||||
},
|
},
|
||||||
set stage(newStage: Stage) {
|
set stage(newStage: Stage) {
|
||||||
stage = newStage;
|
stage = newStage;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const konvaStageState = createState();
|
export const konvaStageState = createState();
|
||||||
|
|||||||
@@ -1,48 +1,44 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
export const STATE_DATA = Symbol("state-data");
|
export const STATE_DATA = Symbol("state-data");
|
||||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||||
|
|
||||||
type OnlyData<T> = {
|
export interface Persistable<D> {
|
||||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
[STATE_DATA]: D;
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface Persistable<D> {
|
export function withPersistence<D extends object, T extends Persistable<D>>(
|
||||||
[STATE_DATA]: D;
|
key: string,
|
||||||
}
|
state: T,
|
||||||
|
debounceMs = DEBOUNCE_DURATION,
|
||||||
export function withPersistence<D extends object, T extends Persistable<D>>(
|
): T {
|
||||||
key: string,
|
if (!browser) return state;
|
||||||
state: T,
|
|
||||||
debounceMs = DEBOUNCE_DURATION,
|
const saved = localStorage.getItem(key);
|
||||||
): T {
|
if (saved) {
|
||||||
if (!browser) return state;
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
const saved = localStorage.getItem(key);
|
|
||||||
if (saved) {
|
if (parsed) {
|
||||||
try {
|
state[STATE_DATA] = parsed;
|
||||||
const parsed = JSON.parse(saved);
|
}
|
||||||
|
} catch (e) {
|
||||||
if (parsed) {
|
console.error(`Error repairing state for ${key}`, e);
|
||||||
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]));
|
||||||
|
|
||||||
$effect.root(() => {
|
if (debounceMs > 0) {
|
||||||
$effect(() => {
|
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||||
const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
|
return () => clearTimeout(timeout);
|
||||||
|
} else {
|
||||||
if (debounceMs > 0) {
|
localStorage.setItem(key, data);
|
||||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
}
|
||||||
return () => clearTimeout(timeout);
|
});
|
||||||
} else {
|
});
|
||||||
localStorage.setItem(key, data);
|
|
||||||
}
|
return state;
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,83 +1,83 @@
|
|||||||
import { type TextAlignType } from "$lib/constants";
|
import { type TextAlignType } from "$lib/constants";
|
||||||
import type { HexColor } from "$lib/types";
|
import type { HexColor } from "$lib/types";
|
||||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||||
|
|
||||||
export type TextConfig = {
|
export type TextConfig = {
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
color: HexColor;
|
color: HexColor;
|
||||||
align: TextAlignType;
|
align: TextAlignType;
|
||||||
paddingX: number;
|
paddingX: number;
|
||||||
offsetY: number;
|
offsetY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createState() {
|
function createState() {
|
||||||
const defaults: TextConfig = {
|
const defaults: TextConfig = {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontFamily: "Arial",
|
fontFamily: "Arial",
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
align: "center",
|
align: "center",
|
||||||
paddingX: 10,
|
paddingX: 10,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
};
|
};
|
||||||
let state: TextConfig = $state({ ...defaults });
|
const state: TextConfig = $state({ ...defaults });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get fontSize() {
|
get fontSize() {
|
||||||
return state.fontSize;
|
return state.fontSize;
|
||||||
},
|
},
|
||||||
set fontSize(value: number) {
|
set fontSize(value: number) {
|
||||||
state.fontSize = value;
|
state.fontSize = value;
|
||||||
},
|
},
|
||||||
get fontFamily() {
|
get fontFamily() {
|
||||||
return state.fontFamily;
|
return state.fontFamily;
|
||||||
},
|
},
|
||||||
set fontFamily(fontFamily: string) {
|
set fontFamily(fontFamily: string) {
|
||||||
state.fontFamily = fontFamily;
|
state.fontFamily = fontFamily;
|
||||||
},
|
},
|
||||||
get color() {
|
get color() {
|
||||||
return state.color;
|
return state.color;
|
||||||
},
|
},
|
||||||
set color(color: HexColor) {
|
set color(color: HexColor) {
|
||||||
state.color = color;
|
state.color = color;
|
||||||
},
|
},
|
||||||
get align() {
|
get align() {
|
||||||
return state.align;
|
return state.align;
|
||||||
},
|
},
|
||||||
set align(align: TextAlignType) {
|
set align(align: TextAlignType) {
|
||||||
state.align = align;
|
state.align = align;
|
||||||
},
|
},
|
||||||
get paddingX() {
|
get paddingX() {
|
||||||
return state.paddingX;
|
return state.paddingX;
|
||||||
},
|
},
|
||||||
set paddingX(paddingX: number) {
|
set paddingX(paddingX: number) {
|
||||||
state.paddingX = paddingX;
|
state.paddingX = paddingX;
|
||||||
},
|
},
|
||||||
get offsetY() {
|
get offsetY() {
|
||||||
return state.offsetY;
|
return state.offsetY;
|
||||||
},
|
},
|
||||||
set offsetY(offsetY: number) {
|
set offsetY(offsetY: number) {
|
||||||
state.offsetY = offsetY;
|
state.offsetY = offsetY;
|
||||||
},
|
},
|
||||||
get [STATE_DATA]() {
|
get [STATE_DATA]() {
|
||||||
return {
|
return {
|
||||||
fontSize: state.fontSize,
|
fontSize: state.fontSize,
|
||||||
fontFamily: state.fontFamily,
|
fontFamily: state.fontFamily,
|
||||||
color: state.color,
|
color: state.color,
|
||||||
align: state.align,
|
align: state.align,
|
||||||
paddingX: state.paddingX,
|
paddingX: state.paddingX,
|
||||||
offsetY: state.offsetY,
|
offsetY: state.offsetY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
set [STATE_DATA](newConfig: TextConfig) {
|
set [STATE_DATA](newConfig: TextConfig) {
|
||||||
state.fontSize = newConfig.fontSize;
|
state.fontSize = newConfig.fontSize;
|
||||||
state.fontFamily = newConfig.fontFamily;
|
state.fontFamily = newConfig.fontFamily;
|
||||||
state.color = newConfig.color;
|
state.color = newConfig.color;
|
||||||
state.align = newConfig.align;
|
state.align = newConfig.align;
|
||||||
state.paddingX = newConfig.paddingX;
|
state.paddingX = newConfig.paddingX;
|
||||||
state.offsetY = newConfig.offsetY;
|
state.offsetY = newConfig.offsetY;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textConfigState = withPersistence("text-config", createState());
|
export const textConfigState = withPersistence("text-config", createState());
|
||||||
|
|||||||
+43
-40
@@ -1,40 +1,43 @@
|
|||||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||||
|
|
||||||
export interface TextItem {
|
export interface TextItem {
|
||||||
text: string;
|
text: string;
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({ text, id: idx }));
|
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({
|
||||||
|
text,
|
||||||
export function createState() {
|
id: idx,
|
||||||
let texts: Array<TextItem> = $state(defaultTexts);
|
}));
|
||||||
let nextId = $state(defaultTexts.length);
|
|
||||||
|
export function createState() {
|
||||||
return {
|
let texts: Array<TextItem> = $state(defaultTexts);
|
||||||
get texts() {
|
let nextId = $state(defaultTexts.length);
|
||||||
return texts;
|
|
||||||
},
|
return {
|
||||||
addText(text: string) {
|
get texts() {
|
||||||
if (text.trim().length === 0) return;
|
return texts;
|
||||||
texts.push({ text, id: nextId });
|
},
|
||||||
nextId++;
|
addText(text: string) {
|
||||||
},
|
if (text.trim().length === 0) return;
|
||||||
removeText(id: number) {
|
texts.push({ text, id: nextId });
|
||||||
texts = texts.filter((textItem) => textItem.id !== id);
|
nextId++;
|
||||||
},
|
},
|
||||||
clear() {
|
removeText(id: number) {
|
||||||
texts = [];
|
texts = texts.filter((textItem) => textItem.id !== id);
|
||||||
nextId = 0;
|
},
|
||||||
},
|
clear() {
|
||||||
get [STATE_DATA]() {
|
texts = [];
|
||||||
return texts.map(({ text }) => text);
|
nextId = 0;
|
||||||
},
|
},
|
||||||
set [STATE_DATA](newTexts: Array<string>) {
|
get [STATE_DATA]() {
|
||||||
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
return texts.map(({ text }) => text);
|
||||||
nextId = newTexts.length;
|
},
|
||||||
},
|
set [STATE_DATA](newTexts: Array<string>) {
|
||||||
};
|
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
||||||
}
|
nextId = newTexts.length;
|
||||||
|
},
|
||||||
export const textsState = withPersistence("texts", createState());
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const textsState = withPersistence("texts", createState());
|
||||||
|
|||||||
+28
-28
@@ -1,28 +1,28 @@
|
|||||||
import { Theme, type ThemeType } from "$lib/constants";
|
import { Theme, type ThemeType } from "$lib/constants";
|
||||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||||
|
|
||||||
function createState() {
|
function createState() {
|
||||||
let current: ThemeType = $state(Theme.LIGHT);
|
let current: ThemeType = $state(Theme.LIGHT);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get current() {
|
get current() {
|
||||||
return current;
|
return current;
|
||||||
},
|
},
|
||||||
set current(value: ThemeType) {
|
set current(value: ThemeType) {
|
||||||
current = value;
|
current = value;
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||||
},
|
},
|
||||||
get [STATE_DATA]() {
|
get [STATE_DATA]() {
|
||||||
return {
|
return {
|
||||||
current,
|
current,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
set [STATE_DATA](data: { current: ThemeType }) {
|
set [STATE_DATA](data: { current: ThemeType }) {
|
||||||
current = data.current;
|
current = data.current;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themeState = withPersistence("theme", createState());
|
export const themeState = withPersistence("theme", createState());
|
||||||
|
|||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import "vitest-canvas-mock";
|
import "vitest-canvas-mock";
|
||||||
import "web-animations-js";
|
import "web-animations-js";
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import CropInline from "$components/image/CropInline.svelte";
|
import CropInline from "$components/image/CropInline.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("CropInline.svelte", () => {
|
describe("CropInline.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
const { container } = render(CropInline);
|
const { container } = render(CropInline);
|
||||||
expect(container).toBeInTheDocument();
|
expect(container).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import ImageManager from "$components/image/ImageManager.svelte";
|
import ImageManager from "$components/image/ImageManager.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
describe("ImageManager.svelte", () => {
|
describe("ImageManager.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(ImageManager);
|
render(ImageManager);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||||
import { themeState } from "$states/theme.svelte";
|
import { themeState } from "$states/theme.svelte";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("AppHeader.svelte", () => {
|
describe("AppHeader.svelte", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
themeState.current = "dark";
|
themeState.current = "dark";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle theme on button click", async () => {
|
it("should toggle theme on button click", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(AppHeader);
|
render(AppHeader);
|
||||||
|
|
||||||
const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
|
const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
|
||||||
|
|
||||||
themeState.current = "dark";
|
themeState.current = "dark";
|
||||||
await user.click(toggleButton);
|
await user.click(toggleButton);
|
||||||
expect(themeState.current).toBe("light");
|
expect(themeState.current).toBe("light");
|
||||||
|
|
||||||
await user.click(toggleButton);
|
await user.click(toggleButton);
|
||||||
expect(themeState.current).toBe("dark");
|
expect(themeState.current).toBe("dark");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import CardTest from "./CardTest.svelte";
|
import CardTest from "./CardTest.svelte";
|
||||||
|
|
||||||
describe("Card.svelte", () => {
|
describe("Card.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(CardTest);
|
render(CardTest);
|
||||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("card-content")).toBeInTheDocument();
|
expect(screen.getByTestId("card-content")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from "$components/layout/Card.svelte";
|
import Card from "$components/layout/Card.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card title="Test Title">
|
<Card title="Test Title">
|
||||||
<div data-testid="card-content">Test content</div>
|
<div data-testid="card-content">Test content</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import InputGroupTest from "./InputGroupTest.svelte";
|
import InputGroupTest from "./InputGroupTest.svelte";
|
||||||
|
|
||||||
describe("InputGroup.svelte", () => {
|
describe("InputGroup.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(InputGroupTest);
|
render(InputGroupTest);
|
||||||
expect(screen.getByTestId("input1")).toBeInTheDocument();
|
expect(screen.getByTestId("input1")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("input2")).toBeInTheDocument();
|
expect(screen.getByTestId("input2")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("button")).toBeInTheDocument();
|
expect(screen.getByTestId("button")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<input type="text" data-testid="input1" />
|
<input type="text" data-testid="input1" />
|
||||||
<input type="text" data-testid="input2" />
|
<input type="text" data-testid="input2" />
|
||||||
<button data-testid="button">Кнопка</button>
|
<button data-testid="button">Кнопка</button>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("PanelBar.svelte", () => {
|
describe("PanelBar.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
const { container } = render(PanelBar);
|
const { container } = render(PanelBar);
|
||||||
expect(container).toBeInTheDocument();
|
expect(container).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import SettingsGridTest from "./SettingsGridTest.svelte";
|
import SettingsGridTest from "./SettingsGridTest.svelte";
|
||||||
|
|
||||||
describe("SettingsGrid.svelte", () => {
|
describe("SettingsGrid.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(SettingsGridTest);
|
render(SettingsGridTest);
|
||||||
expect(screen.getByTestId("item1")).toBeInTheDocument();
|
expect(screen.getByTestId("item1")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("item2")).toBeInTheDocument();
|
expect(screen.getByTestId("item2")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("item3")).toBeInTheDocument();
|
expect(screen.getByTestId("item3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SettingsGrid>
|
<SettingsGrid>
|
||||||
<div data-testid="item1">Item 1</div>
|
<div data-testid="item1">Item 1</div>
|
||||||
<div data-testid="item2">Item 2</div>
|
<div data-testid="item2">Item 2</div>
|
||||||
<div data-testid="item3">Item 3</div>
|
<div data-testid="item3">Item 3</div>
|
||||||
</SettingsGrid>
|
</SettingsGrid>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import SettingsRowTest from "./SettingsRowTest.svelte";
|
import SettingsRowTest from "./SettingsRowTest.svelte";
|
||||||
|
|
||||||
describe("SettingsRow.svelte", () => {
|
describe("SettingsRow.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(SettingsRowTest);
|
render(SettingsRowTest);
|
||||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("test-input")).toBeInTheDocument();
|
expect(screen.getByTestId("test-input")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SettingsRow label="Test Label">
|
<SettingsRow label="Test Label">
|
||||||
<input type="text" data-testid="test-input" />
|
<input type="text" data-testid="test-input" />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import TextBar from "$components/layout/TextBar.svelte";
|
import TextBar from "$components/layout/TextBar.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("TextBar.svelte", () => {
|
describe("TextBar.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
const { container } = render(TextBar);
|
const { container } = render(TextBar);
|
||||||
expect(container).toBeInTheDocument();
|
expect(container).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import Preview from "$components/panel/Preview.svelte";
|
import Preview from "$components/panel/Preview.svelte";
|
||||||
import { cleanup, render } from "@testing-library/svelte";
|
import { cleanup, render } from "@testing-library/svelte";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Preview.svelte", () => {
|
describe("Preview.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
const { container } = render(Preview, {
|
const { container } = render(Preview, {
|
||||||
props: {
|
props: {
|
||||||
text: "Test text",
|
text: "Test text",
|
||||||
stage: undefined,
|
stage: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container).toBeInTheDocument();
|
expect(container).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have stage element", () => {
|
it("should have stage element", () => {
|
||||||
const { container } = render(Preview, {
|
const { container } = render(Preview, {
|
||||||
props: {
|
props: {
|
||||||
text: "Test",
|
text: "Test",
|
||||||
stage: undefined,
|
stage: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const stage = container.querySelector("canvas");
|
const stage = container.querySelector("canvas");
|
||||||
expect(stage).toBeInTheDocument();
|
expect(stage).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import PreviewAll from "$components/panel/PreviewAll.svelte";
|
import PreviewAll from "$components/panel/PreviewAll.svelte";
|
||||||
import { textsState } from "$states/texts.svelte";
|
import { textsState } from "$states/texts.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { beforeEach, describe, it } from "vitest";
|
import { beforeEach, describe, it } from "vitest";
|
||||||
|
|
||||||
describe("PreviewAll.svelte", () => {
|
describe("PreviewAll.svelte", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
textsState.texts.length = 0;
|
textsState.texts.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(PreviewAll);
|
render(PreviewAll);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
import { SlideDirection } from "$lib/constants";
|
import { SlideDirection } from "$lib/constants";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import PreviewControlsTest from "./PreviewControlsTest.svelte";
|
import PreviewControlsTest from "./PreviewControlsTest.svelte";
|
||||||
|
|
||||||
describe("PreviewControls logic", () => {
|
describe("PreviewControls logic", () => {
|
||||||
it("should increment current and update direction on next click", async () => {
|
it("should increment current and update direction on next click", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(PreviewControlsTest, { props: { current: 0, max: 3 } });
|
render(PreviewControlsTest, { props: { current: 0, max: 3 } });
|
||||||
|
|
||||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||||
|
|
||||||
await user.click(nextBtn);
|
await user.click(nextBtn);
|
||||||
|
|
||||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||||
expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT);
|
expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should decrement current on prev click", async () => {
|
it("should decrement current on prev click", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(PreviewControlsTest, { props: { current: 2, max: 3 } });
|
render(PreviewControlsTest, { props: { current: 2, max: 3 } });
|
||||||
|
|
||||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||||
|
|
||||||
await user.click(prevBtn);
|
await user.click(prevBtn);
|
||||||
|
|
||||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle boundaries and disable buttons", async () => {
|
it("should handle boundaries and disable buttons", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(PreviewControlsTest, { props: { current: 0, max: 2 } });
|
render(PreviewControlsTest, { props: { current: 0, max: 2 } });
|
||||||
|
|
||||||
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
|
||||||
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
const nextBtn = screen.getByRole("button", { name: /next slide/i });
|
||||||
|
|
||||||
expect(prevBtn).toBeDisabled();
|
expect(prevBtn).toBeDisabled();
|
||||||
|
|
||||||
await user.click(nextBtn);
|
await user.click(nextBtn);
|
||||||
|
|
||||||
expect(screen.getByTestId("current").textContent).toBe("1");
|
expect(screen.getByTestId("current").textContent).toBe("1");
|
||||||
expect(nextBtn).toBeDisabled();
|
expect(nextBtn).toBeDisabled();
|
||||||
expect(prevBtn).not.toBeDisabled();
|
expect(prevBtn).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PreviewControls from "$components/panel/PreviewControls.svelte";
|
import PreviewControls from "$components/panel/PreviewControls.svelte";
|
||||||
|
|
||||||
import { SlideDirection } from "$lib/constants";
|
import { SlideDirection } from "$lib/constants";
|
||||||
|
|
||||||
let { current = 0, max = 5 } = $props();
|
let { current = 0, max = 5 } = $props();
|
||||||
let direction = $state(SlideDirection.NEXT);
|
let direction = $state(SlideDirection.NEXT);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PreviewControls bind:current bind:direction {max} />
|
<PreviewControls bind:current bind:direction {max} />
|
||||||
|
|
||||||
<div data-testid="current">{current}</div>
|
<div data-testid="current">{current}</div>
|
||||||
<div data-testid="direction">{direction}</div>
|
<div data-testid="direction">{direction}</div>
|
||||||
|
|||||||
@@ -1,102 +1,103 @@
|
|||||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||||
import { downloadService } from "$services/downloadService";
|
import { downloadService } from "$services/downloadService";
|
||||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||||
import { STATE_DATA } from "$states/persisted.svelte";
|
import { STATE_DATA } from "$states/persisted.svelte";
|
||||||
import { textsState } from "$states/texts.svelte";
|
import { textsState } from "$states/texts.svelte";
|
||||||
import { render, screen, waitFor } from "@testing-library/svelte";
|
import { render, screen, waitFor } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { Stage } from "svelte-konva";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
vi.mock("$services/downloadService", () => ({
|
|
||||||
downloadService: {
|
vi.mock("$services/downloadService", () => ({
|
||||||
downloadAll: vi.fn(),
|
downloadService: {
|
||||||
downloadPanel: vi.fn(),
|
downloadAll: vi.fn(),
|
||||||
},
|
downloadPanel: vi.fn(),
|
||||||
}));
|
},
|
||||||
|
}));
|
||||||
vi.mock("./Preview.svelte", () => ({
|
|
||||||
default: { render: () => ({}) },
|
vi.mock("./Preview.svelte", () => ({
|
||||||
}));
|
default: { render: () => ({}) },
|
||||||
describe("PreviewManager Integration", () => {
|
}));
|
||||||
beforeEach(() => {
|
describe("PreviewManager Integration", () => {
|
||||||
vi.clearAllMocks();
|
beforeEach(() => {
|
||||||
textsState[STATE_DATA] = [];
|
vi.clearAllMocks();
|
||||||
});
|
textsState[STATE_DATA] = [];
|
||||||
|
});
|
||||||
it("should show empty state by aria-label when no texts", () => {
|
|
||||||
render(PreviewManager);
|
it("should show empty state by aria-label when no texts", () => {
|
||||||
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
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"];
|
it("should toggle navigation buttons availability based on texts length", async () => {
|
||||||
render(PreviewManager);
|
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 });
|
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();
|
expect(nextBtn).not.toBeDisabled();
|
||||||
});
|
expect(prevBtn).toBeDisabled();
|
||||||
|
});
|
||||||
it("should send correct data to downloadAll service", async () => {
|
|
||||||
const user = userEvent.setup();
|
it("should send correct data to downloadAll service", async () => {
|
||||||
textsState[STATE_DATA] = ["Apple", "Banana"];
|
const user = userEvent.setup();
|
||||||
render(PreviewManager);
|
textsState[STATE_DATA] = ["Apple", "Banana"];
|
||||||
|
render(PreviewManager);
|
||||||
await user.click(screen.getByRole("button", { name: /download all/i }));
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /download all/i }));
|
||||||
expect(downloadService.downloadAll).toHaveBeenCalledWith(
|
|
||||||
expect.arrayContaining([
|
expect(downloadService.downloadAll).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ filename: "Apple" }),
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ filename: "Banana" }),
|
expect.objectContaining({ filename: "Apple" }),
|
||||||
]),
|
expect.objectContaining({ filename: "Banana" }),
|
||||||
);
|
]),
|
||||||
});
|
);
|
||||||
|
});
|
||||||
it("should call downloadPanel with current active text", async () => {
|
|
||||||
const user = userEvent.setup();
|
it("should call downloadPanel with current active text", async () => {
|
||||||
textsState[STATE_DATA] = ["First", "Second"];
|
const user = userEvent.setup();
|
||||||
konvaStageState.stage = { node: { id: "stage-ref" } } as any;
|
textsState[STATE_DATA] = ["First", "Second"];
|
||||||
|
konvaStageState.stage = { node: { id: "stage-ref" } } as unknown as Stage;
|
||||||
render(PreviewManager);
|
|
||||||
|
render(PreviewManager);
|
||||||
// Переходим на второй слайд
|
|
||||||
await user.click(screen.getByRole("button", { name: /next slide/i }));
|
// Переходим на второй слайд
|
||||||
await user.click(screen.getByRole("button", { name: /download current/i }));
|
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");
|
|
||||||
});
|
expect(downloadService.downloadPanel).toHaveBeenCalledWith(expect.anything(), "Second");
|
||||||
|
});
|
||||||
it("should return to empty state when texts are removed", async () => {
|
|
||||||
textsState[STATE_DATA] = ["Temp"];
|
it("should return to empty state when texts are removed", async () => {
|
||||||
render(PreviewManager);
|
textsState[STATE_DATA] = ["Temp"];
|
||||||
|
render(PreviewManager);
|
||||||
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
|
|
||||||
|
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
|
||||||
textsState[STATE_DATA] = [];
|
|
||||||
|
textsState[STATE_DATA] = [];
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
});
|
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it("should automatically correct current index on items deletion", async () => {
|
|
||||||
const user = userEvent.setup();
|
it("should automatically correct current index on items deletion", async () => {
|
||||||
textsState[STATE_DATA] = ["1", "2", "3"];
|
const user = userEvent.setup();
|
||||||
render(PreviewManager);
|
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 }));
|
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"];
|
// Удаляем элементы. Индекс должен упасть с 2 до 0
|
||||||
|
textsState[STATE_DATA] = ["Only one left"];
|
||||||
await waitFor(() => {
|
|
||||||
// Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном)
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled();
|
// Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном)
|
||||||
expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled();
|
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 TextConfig from "$components/text/TextConfig.svelte";
|
||||||
import { cleanup, render } from "@testing-library/svelte";
|
import { cleanup, render } from "@testing-library/svelte";
|
||||||
import { afterEach, describe, it } from "vitest";
|
import { afterEach, describe, it } from "vitest";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TextConfig.svelte", () => {
|
describe("TextConfig.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(TextConfig);
|
render(TextConfig);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import TextInlineEdit from "$components/text/TextInlineEdit.svelte";
|
import TextInlineEdit from "$components/text/TextInlineEdit.svelte";
|
||||||
import { cleanup, render, screen } from "@testing-library/svelte";
|
import { cleanup, render, screen } from "@testing-library/svelte";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TextInlineEdit.svelte", () => {
|
describe("TextInlineEdit.svelte", () => {
|
||||||
it("should render with initial text", () => {
|
it("should render with initial text", () => {
|
||||||
render(TextInlineEdit, {
|
render(TextInlineEdit, {
|
||||||
props: {
|
props: {
|
||||||
id: 1,
|
id: 1,
|
||||||
text: "Test text",
|
text: "Test text",
|
||||||
ondelete: () => {},
|
ondelete: () => {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("textbox");
|
||||||
expect(input).toBeInTheDocument();
|
expect(input).toBeInTheDocument();
|
||||||
expect(input).toHaveValue("Test text");
|
expect(input).toHaveValue("Test text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with empty text", () => {
|
it("should render with empty text", () => {
|
||||||
render(TextInlineEdit, {
|
render(TextInlineEdit, {
|
||||||
props: {
|
props: {
|
||||||
id: 1,
|
id: 1,
|
||||||
text: "",
|
text: "",
|
||||||
ondelete: () => {},
|
ondelete: () => {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("textbox");
|
||||||
expect(input).toHaveValue("");
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
import TextInput from "$components/text/TextInput.svelte";
|
import TextInput from "$components/text/TextInput.svelte";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
describe("TextInput.svelte", () => {
|
describe("TextInput.svelte", () => {
|
||||||
it("should render with initial value", () => {
|
it("should render with initial value", () => {
|
||||||
render(TextInput, {
|
render(TextInput, {
|
||||||
props: {
|
props: {
|
||||||
text: "Test text",
|
text: "Test text",
|
||||||
onenter: vi.fn(),
|
onenter: vi.fn(),
|
||||||
ariaLabel: "Test input",
|
ariaLabel: "Test input",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||||
expect(input).toBeInTheDocument();
|
expect(input).toBeInTheDocument();
|
||||||
expect(input).toHaveValue("Test text");
|
expect(input).toHaveValue("Test text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update value on input", async () => {
|
it("should update value on input", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(TextInput, {
|
render(TextInput, {
|
||||||
props: {
|
props: {
|
||||||
text: "Initial",
|
text: "Initial",
|
||||||
onenter: vi.fn(),
|
onenter: vi.fn(),
|
||||||
ariaLabel: "Test input",
|
ariaLabel: "Test input",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||||
await user.clear(input);
|
await user.clear(input);
|
||||||
await user.type(input, "Updated text");
|
await user.type(input, "Updated text");
|
||||||
|
|
||||||
expect(input).toHaveValue("Updated text");
|
expect(input).toHaveValue("Updated text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call onenter only on Enter key and not on others", async () => {
|
it("should call onenter only on Enter key and not on others", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onenterSpy = vi.fn();
|
const onenterSpy = vi.fn();
|
||||||
|
|
||||||
render(TextInput, {
|
render(TextInput, {
|
||||||
props: {
|
props: {
|
||||||
text: "test",
|
text: "test",
|
||||||
onenter: onenterSpy,
|
onenter: onenterSpy,
|
||||||
ariaLabel: "Test input",
|
ariaLabel: "Test input",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = screen.getByRole("textbox", { name: /test input/i });
|
const input = screen.getByRole("textbox", { name: /test input/i });
|
||||||
|
|
||||||
await user.type(input, "abc");
|
await user.type(input, "abc");
|
||||||
expect(onenterSpy).not.toHaveBeenCalled();
|
expect(onenterSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
expect(onenterSpy).not.toHaveBeenCalled();
|
expect(onenterSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await user.type(input, "{Enter}");
|
await user.type(input, "{Enter}");
|
||||||
expect(onenterSpy).toHaveBeenCalledTimes(1);
|
expect(onenterSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
import TextManager from "$components/text/TextManager.svelte";
|
import TextManager from "$components/text/TextManager.svelte";
|
||||||
import { textsState } from "$states/texts.svelte";
|
import { textsState } from "$states/texts.svelte";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("TextManager", () => {
|
describe("TextManager", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
textsState.clear();
|
textsState.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add text to state and clear input on button click", async () => {
|
it("should add text to state and clear input on button click", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(TextManager);
|
render(TextManager);
|
||||||
|
|
||||||
const input = screen.getByRole("textbox", { name: /input new text/i });
|
const input = screen.getByRole("textbox", { name: /input new text/i });
|
||||||
const addButton = screen.getByRole("button", { name: /add text/i });
|
const addButton = screen.getByRole("button", { name: /add text/i });
|
||||||
|
|
||||||
await user.type(input, "New Note");
|
await user.type(input, "New Note");
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
|
|
||||||
expect(textsState.texts).toHaveLength(1);
|
expect(textsState.texts).toHaveLength(1);
|
||||||
expect(textsState.texts[0].text).toBe("New Note");
|
expect(textsState.texts[0].text).toBe("New Note");
|
||||||
expect(input).toHaveValue("");
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add text on enter key", async () => {
|
it("should add text on enter key", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(TextManager);
|
render(TextManager);
|
||||||
|
|
||||||
const input = screen.getByRole("textbox", { name: /input new text/i });
|
const input = screen.getByRole("textbox", { name: /input new text/i });
|
||||||
|
|
||||||
await user.type(input, "Enter Note{Enter}");
|
await user.type(input, "Enter Note{Enter}");
|
||||||
|
|
||||||
expect(textsState.texts).toHaveLength(1);
|
expect(textsState.texts).toHaveLength(1);
|
||||||
expect(textsState.texts[0].text).toBe("Enter Note");
|
expect(textsState.texts[0].text).toBe("Enter Note");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display all items from state", async () => {
|
it("should display all items from state", async () => {
|
||||||
textsState.addText("First");
|
textsState.addText("First");
|
||||||
textsState.addText("Second");
|
textsState.addText("Second");
|
||||||
|
|
||||||
render(TextManager);
|
render(TextManager);
|
||||||
|
|
||||||
const items = screen.getAllByRole("listitem");
|
const items = screen.getAllByRole("listitem");
|
||||||
expect(items).toHaveLength(2);
|
expect(items).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove text from state when delete button is clicked", async () => {
|
it("should remove text from state when delete button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
textsState.addText("To be deleted");
|
textsState.addText("To be deleted");
|
||||||
|
|
||||||
render(TextManager);
|
render(TextManager);
|
||||||
|
|
||||||
const deleteBtn = screen.getByRole("button", { name: /delete/i });
|
const deleteBtn = screen.getByRole("button", { name: /delete/i });
|
||||||
await user.click(deleteBtn);
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
expect(textsState.texts).toHaveLength(0);
|
expect(textsState.texts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,43 @@
|
|||||||
import { TextAlign } from "$lib/constants";
|
import { TextAlign } from "$lib/constants";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import AlignmentTest from "./AlignmentTest.svelte";
|
import AlignmentTest from "./AlignmentTest.svelte";
|
||||||
|
|
||||||
describe("Alignment.svelte", () => {
|
describe("Alignment.svelte", () => {
|
||||||
it("should update state for every button by clicking in sequence", async () => {
|
it("should update state for every button by clicking in sequence", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(AlignmentTest);
|
render(AlignmentTest);
|
||||||
|
|
||||||
const buttons = screen.getAllByRole("radio");
|
const buttons = screen.getAllByRole("radio");
|
||||||
const stateDisplay = screen.getByTestId("align-value");
|
const stateDisplay = screen.getByTestId("align-value");
|
||||||
|
|
||||||
const allButtonsToClick = [...buttons, buttons[0]];
|
const allButtonsToClick = [...buttons, buttons[0]];
|
||||||
|
|
||||||
for (const button of allButtonsToClick) {
|
for (const button of allButtonsToClick) {
|
||||||
const label = button.getAttribute("aria-label")?.toLowerCase() || "";
|
const label = button.getAttribute("aria-label")?.toLowerCase() || "";
|
||||||
|
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
|
|
||||||
const finalValue = stateDisplay.textContent?.toLowerCase() || "";
|
const finalValue = stateDisplay.textContent?.toLowerCase() || "";
|
||||||
expect(label).toContain(finalValue);
|
expect(label).toContain(finalValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have initial state from props", () => {
|
it("should have initial state from props", () => {
|
||||||
render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
||||||
expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT);
|
expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sync with initial state and change state", async () => {
|
it("should sync with initial state and change state", async () => {
|
||||||
const user = userEvent.setup();
|
const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
||||||
const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
|
const stateDisplay = screen.getByTestId("align-value");
|
||||||
const stateDisplay = screen.getByTestId("align-value");
|
|
||||||
|
expect(stateDisplay.textContent).toBe(TextAlign.RIGHT);
|
||||||
expect(stateDisplay.textContent).toBe(TextAlign.RIGHT);
|
|
||||||
|
await rerender({ align: TextAlign.CENTER });
|
||||||
await rerender({ align: TextAlign.CENTER });
|
|
||||||
|
const centerBtn = screen.getByRole("radio", { name: new RegExp(TextAlign.CENTER, "i") });
|
||||||
const centerBtn = screen.getByRole("radio", { name: new RegExp(TextAlign.CENTER, "i") });
|
expect(centerBtn).toBeChecked();
|
||||||
expect(centerBtn).toBeChecked();
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Alignment from "$components/ui/Alignment.svelte";
|
import Alignment from "$components/ui/Alignment.svelte";
|
||||||
|
|
||||||
import { TextAlign } from "$lib/constants";
|
import { TextAlign } from "$lib/constants";
|
||||||
|
|
||||||
let { align = TextAlign.LEFT } = $props();
|
let { align = TextAlign.LEFT } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Alignment bind:align />
|
<Alignment bind:align />
|
||||||
|
|
||||||
<div data-testid="align-value">{align}</div>
|
<div data-testid="align-value">{align}</div>
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import Badge from "$components/ui/Badge.svelte";
|
import Badge from "$components/ui/Badge.svelte";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("Badge.svelte", () => {
|
describe("Badge.svelte", () => {
|
||||||
it("should render with string text", () => {
|
it("should render with string text", () => {
|
||||||
render(Badge, {
|
render(Badge, {
|
||||||
props: {
|
props: {
|
||||||
text: "Test Badge",
|
text: "Test Badge",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Test Badge")).toBeInTheDocument();
|
expect(screen.getByText("Test Badge")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with number text", () => {
|
it("should render with number text", () => {
|
||||||
render(Badge, {
|
render(Badge, {
|
||||||
props: {
|
props: {
|
||||||
text: 42,
|
text: 42,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("42")).toBeInTheDocument();
|
expect(screen.getByText("42")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with empty string", () => {
|
it("should render with empty string", () => {
|
||||||
render(Badge, {
|
render(Badge, {
|
||||||
props: {
|
props: {
|
||||||
text: "",
|
text: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with zero", () => {
|
it("should render with zero", () => {
|
||||||
render(Badge, {
|
render(Badge, {
|
||||||
props: {
|
props: {
|
||||||
text: 0,
|
text: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("0")).toBeInTheDocument();
|
expect(screen.getByText("0")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,95 +1,95 @@
|
|||||||
import Button from "$components/ui/Button.svelte";
|
import Button from "$components/ui/Button.svelte";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import MockIcon from "./MockIcon.svelte";
|
import MockIcon from "./MockIcon.svelte";
|
||||||
|
|
||||||
describe("Button.svelte", () => {
|
describe("Button.svelte", () => {
|
||||||
it("should render with icon and label", () => {
|
it("should render with icon and label", () => {
|
||||||
render(Button, {
|
render(Button, {
|
||||||
props: {
|
props: {
|
||||||
icon: MockIcon,
|
icon: MockIcon,
|
||||||
label: "Test Button",
|
label: "Test Button",
|
||||||
ariaLabel: "Test button",
|
ariaLabel: "Test button",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Test Button")).toBeInTheDocument();
|
expect(screen.getByText("Test Button")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with icon only", () => {
|
it("should render with icon only", () => {
|
||||||
render(Button, {
|
render(Button, {
|
||||||
props: {
|
props: {
|
||||||
icon: MockIcon,
|
icon: MockIcon,
|
||||||
ariaLabel: "Test button",
|
ariaLabel: "Test button",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: /test button/i });
|
const button = screen.getByRole("button", { name: /test button/i });
|
||||||
expect(button).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
expect(button.textContent.trim()).toBe("");
|
expect(button.textContent.trim()).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call onclick handler", async () => {
|
it("should call onclick handler", async () => {
|
||||||
const onclick = vi.fn();
|
const onclick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(Button, {
|
render(Button, {
|
||||||
props: {
|
props: {
|
||||||
icon: MockIcon,
|
icon: MockIcon,
|
||||||
label: "Click me",
|
label: "Click me",
|
||||||
onclick,
|
onclick,
|
||||||
ariaLabel: "Test button",
|
ariaLabel: "Test button",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: /test button/i });
|
const button = screen.getByRole("button", { name: /test button/i });
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
|
|
||||||
expect(onclick).toHaveBeenCalledTimes(1);
|
expect(onclick).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not throw error when clicked without onclick prop", async () => {
|
it("should not throw error when clicked without onclick prop", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(Button, {
|
render(Button, {
|
||||||
props: {
|
props: {
|
||||||
icon: MockIcon,
|
icon: MockIcon,
|
||||||
label: "Click me",
|
label: "Click me",
|
||||||
ariaLabel: "Test button",
|
ariaLabel: "Test button",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: /test button/i });
|
const button = screen.getByRole("button", { name: /test button/i });
|
||||||
|
|
||||||
expect(() => user.click(button)).not.toThrow();
|
expect(() => user.click(button)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be disabled when disabled prop is true", () => {
|
it("should be disabled when disabled prop is true", () => {
|
||||||
render(Button, {
|
render(Button, {
|
||||||
props: {
|
props: {
|
||||||
icon: MockIcon,
|
icon: MockIcon,
|
||||||
label: "Disabled",
|
label: "Disabled",
|
||||||
disabled: true,
|
disabled: true,
|
||||||
ariaLabel: "Test button",
|
ariaLabel: "Test button",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: /test button/i });
|
const button = screen.getByRole("button", { name: /test button/i });
|
||||||
expect(button).toBeDisabled();
|
expect(button).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not be disabled by default", () => {
|
it("should not be disabled by default", () => {
|
||||||
render(Button, {
|
render(Button, {
|
||||||
props: {
|
props: {
|
||||||
icon: MockIcon,
|
icon: MockIcon,
|
||||||
label: "Enabled",
|
label: "Enabled",
|
||||||
ariaLabel: "Test button",
|
ariaLabel: "Test button",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: /test button/i });
|
const button = screen.getByRole("button", { name: /test button/i });
|
||||||
expect(button).not.toBeDisabled();
|
expect(button).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
describe("ColorPicker.svelte", () => {
|
describe("ColorPicker.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(ColorPicker, {
|
render(ColorPicker, {
|
||||||
props: {
|
props: {
|
||||||
value: "#ffffff",
|
value: "#ffffff",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<svg data-testid="mock-icon" viewBox="0 0 24 24">
|
<svg data-testid="mock-icon" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
</svg>
|
</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 RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||||
import { fireEvent, render, screen } from "@testing-library/svelte";
|
import { fireEvent, render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
describe("RangeSlider.svelte", () => {
|
describe("RangeSlider.svelte", () => {
|
||||||
it("should call onchange handler", async () => {
|
it("should call onchange handler", async () => {
|
||||||
const onchange = vi.fn();
|
const onchange = vi.fn();
|
||||||
|
|
||||||
render(RangeSlider, {
|
render(RangeSlider, {
|
||||||
props: {
|
props: {
|
||||||
value: 50,
|
value: 50,
|
||||||
onchange,
|
onchange,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const slider = screen.getByRole("slider");
|
const slider = screen.getByRole("slider");
|
||||||
await fireEvent.change(slider, { target: { value: "75" } });
|
await fireEvent.change(slider, { target: { value: "75" } });
|
||||||
|
|
||||||
expect(onchange).toHaveBeenCalledTimes(1);
|
expect(onchange).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
describe("SelectFont.svelte", () => {
|
describe("SelectFont.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(SelectFont, {
|
render(SelectFont, {
|
||||||
props: {
|
props: {
|
||||||
value: "Arial",
|
value: "Arial",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,68 +1,70 @@
|
|||||||
import * as Constants from "$lib/constants";
|
import * as Constants from "$lib/constants";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("Application Constants Logic", () => {
|
describe("Application Constants Logic", () => {
|
||||||
describe("Typography & Panel Constraints", () => {
|
describe("Typography & Panel Constraints", () => {
|
||||||
it("should have default values within allowed boundaries", () => {
|
it("should have default values within allowed boundaries", () => {
|
||||||
const { TYPOGRAPHY, PANEL_SETTINGS } = Constants;
|
const { TYPOGRAPHY, PANEL_SETTINGS } = Constants;
|
||||||
|
|
||||||
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN);
|
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN);
|
||||||
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX);
|
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX);
|
||||||
|
|
||||||
expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
|
expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
|
||||||
|
|
||||||
expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(PANEL_SETTINGS.PANEL_HEIGHT_MAX);
|
expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(
|
||||||
|
PANEL_SETTINGS.PANEL_HEIGHT_MAX,
|
||||||
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN);
|
);
|
||||||
});
|
|
||||||
|
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN);
|
||||||
it("should not have duplicate values in lists", () => {
|
});
|
||||||
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
|
||||||
|
it("should not have duplicate values in lists", () => {
|
||||||
const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES);
|
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
||||||
expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length);
|
|
||||||
|
const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES);
|
||||||
const uniqueFormats = new Set(IMAGE_SETTINGS.SUPPORTED_FORMATS);
|
expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length);
|
||||||
expect(uniqueFormats.size).toBe(IMAGE_SETTINGS.SUPPORTED_FORMATS.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;
|
describe("Integrity & Formats", () => {
|
||||||
|
it("should have valid format patterns for colors and mime-types", () => {
|
||||||
expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i);
|
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
|
||||||
|
|
||||||
IMAGE_SETTINGS.SUPPORTED_FORMATS.forEach((format) => {
|
expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
expect(format).toMatch(/^image\/(jpeg|jpg|png|webp|gif)$/);
|
|
||||||
});
|
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("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("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;
|
describe("Assets Existence", () => {
|
||||||
|
it("should verify that the default background image exists", () => {
|
||||||
const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, "");
|
const { DEFAULT_BACKGROUND_IMAGE } = Constants.PANEL_SETTINGS;
|
||||||
const fullPath = path.resolve(process.cwd(), "static", relativePath);
|
|
||||||
|
const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, "");
|
||||||
const exists = fs.existsSync(fullPath);
|
const fullPath = path.resolve(process.cwd(), "static", relativePath);
|
||||||
|
|
||||||
expect(exists, `Image not found at: ${fullPath}`).toBe(true);
|
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 { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("error.types", () => {
|
describe("error.types", () => {
|
||||||
describe("AppError", () => {
|
describe("AppError", () => {
|
||||||
it("should create AppError with message and code", () => {
|
it("should create AppError with message and code", () => {
|
||||||
const error = new AppError("Test error message", "TEST_CODE");
|
const error = new AppError("Test error message", "TEST_CODE");
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error.name).toBe("AppError");
|
expect(error.name).toBe("AppError");
|
||||||
expect(error.message).toBe("Test error message");
|
expect(error.message).toBe("Test error message");
|
||||||
expect(error.code).toBe("TEST_CODE");
|
expect(error.code).toBe("TEST_CODE");
|
||||||
expect(error.details).toBeUndefined();
|
expect(error.details).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create AppError with message, code and details", () => {
|
it("should create AppError with message, code and details", () => {
|
||||||
const details = { userId: 123, action: "test" };
|
const details = { userId: 123, action: "test" };
|
||||||
const error = new AppError("Test error message", "TEST_CODE", details);
|
const error = new AppError("Test error message", "TEST_CODE", details);
|
||||||
|
|
||||||
expect(error.details).toEqual(details);
|
expect(error.details).toEqual(details);
|
||||||
expect(error.message).toBe("Test error message");
|
expect(error.message).toBe("Test error message");
|
||||||
expect(error.code).toBe("TEST_CODE");
|
expect(error.code).toBe("TEST_CODE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have stack trace", () => {
|
it("should have stack trace", () => {
|
||||||
const error = new AppError("Test error", "TEST_CODE");
|
const error = new AppError("Test error", "TEST_CODE");
|
||||||
|
|
||||||
expect(error.stack).toBeDefined();
|
expect(error.stack).toBeDefined();
|
||||||
expect(typeof error.stack).toBe("string");
|
expect(typeof error.stack).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be throwable and catchable", () => {
|
it("should be throwable and catchable", () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
throw new AppError("Test error", "TEST_CODE");
|
throw new AppError("Test error", "TEST_CODE");
|
||||||
}).toThrow(AppError);
|
}).toThrow(AppError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be catchable as Error", () => {
|
it("should be catchable as Error", () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
throw new AppError("Test error", "TEST_CODE");
|
throw new AppError("Test error", "TEST_CODE");
|
||||||
}).toThrow(Error);
|
}).toThrow(Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ImageError", () => {
|
describe("ImageError", () => {
|
||||||
it("should create ImageError with message", () => {
|
it("should create ImageError with message", () => {
|
||||||
const error = new ImageError("Image loading failed");
|
const error = new ImageError("Image loading failed");
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error).toBeInstanceOf(ImageError);
|
expect(error).toBeInstanceOf(ImageError);
|
||||||
expect(error.name).toBe("AppError");
|
expect(error.name).toBe("AppError");
|
||||||
expect(error.message).toBe("Image loading failed");
|
expect(error.message).toBe("Image loading failed");
|
||||||
expect(error.code).toBe("IMAGE_ERROR");
|
expect(error.code).toBe("IMAGE_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have correct error code", () => {
|
it("should have correct error code", () => {
|
||||||
const error = new ImageError("Test");
|
const error = new ImageError("Test");
|
||||||
|
|
||||||
expect(error.code).toBe("IMAGE_ERROR");
|
expect(error.code).toBe("IMAGE_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be identifiable as ImageError", () => {
|
it("should be identifiable as ImageError", () => {
|
||||||
const error = new ImageError("Test");
|
const error = new ImageError("Test");
|
||||||
|
|
||||||
expect(error instanceof ImageError).toBe(true);
|
expect(error instanceof ImageError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TextError", () => {
|
describe("TextError", () => {
|
||||||
it("should create TextError with message", () => {
|
it("should create TextError with message", () => {
|
||||||
const error = new TextError("Text validation failed");
|
const error = new TextError("Text validation failed");
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error).toBeInstanceOf(TextError);
|
expect(error).toBeInstanceOf(TextError);
|
||||||
expect(error.name).toBe("AppError");
|
expect(error.name).toBe("AppError");
|
||||||
expect(error.message).toBe("Text validation failed");
|
expect(error.message).toBe("Text validation failed");
|
||||||
expect(error.code).toBe("TEXT_ERROR");
|
expect(error.code).toBe("TEXT_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have correct error code", () => {
|
it("should have correct error code", () => {
|
||||||
const error = new TextError("Test");
|
const error = new TextError("Test");
|
||||||
|
|
||||||
expect(error.code).toBe("TEXT_ERROR");
|
expect(error.code).toBe("TEXT_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be identifiable as TextError", () => {
|
it("should be identifiable as TextError", () => {
|
||||||
const error = new TextError("Test");
|
const error = new TextError("Test");
|
||||||
|
|
||||||
expect(error instanceof TextError).toBe(true);
|
expect(error instanceof TextError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CanvasError", () => {
|
describe("CanvasError", () => {
|
||||||
it("should create CanvasError with message", () => {
|
it("should create CanvasError with message", () => {
|
||||||
const error = new CanvasError("Canvas rendering failed");
|
const error = new CanvasError("Canvas rendering failed");
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error).toBeInstanceOf(CanvasError);
|
expect(error).toBeInstanceOf(CanvasError);
|
||||||
expect(error.name).toBe("AppError");
|
expect(error.name).toBe("AppError");
|
||||||
expect(error.message).toBe("Canvas rendering failed");
|
expect(error.message).toBe("Canvas rendering failed");
|
||||||
expect(error.code).toBe("CANVAS_ERROR");
|
expect(error.code).toBe("CANVAS_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have correct error code", () => {
|
it("should have correct error code", () => {
|
||||||
const error = new CanvasError("Test");
|
const error = new CanvasError("Test");
|
||||||
|
|
||||||
expect(error.code).toBe("CANVAS_ERROR");
|
expect(error.code).toBe("CANVAS_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be identifiable as CanvasError", () => {
|
it("should be identifiable as CanvasError", () => {
|
||||||
const error = new CanvasError("Test");
|
const error = new CanvasError("Test");
|
||||||
|
|
||||||
expect(error instanceof CanvasError).toBe(true);
|
expect(error instanceof CanvasError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("StorageError", () => {
|
describe("StorageError", () => {
|
||||||
it("should create StorageError with message", () => {
|
it("should create StorageError with message", () => {
|
||||||
const error = new StorageError("Storage operation failed");
|
const error = new StorageError("Storage operation failed");
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error).toBeInstanceOf(StorageError);
|
expect(error).toBeInstanceOf(StorageError);
|
||||||
expect(error.name).toBe("AppError");
|
expect(error.name).toBe("AppError");
|
||||||
expect(error.message).toBe("Storage operation failed");
|
expect(error.message).toBe("Storage operation failed");
|
||||||
expect(error.code).toBe("STORAGE_ERROR");
|
expect(error.code).toBe("STORAGE_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have correct error code", () => {
|
it("should have correct error code", () => {
|
||||||
const error = new StorageError("Test");
|
const error = new StorageError("Test");
|
||||||
|
|
||||||
expect(error.code).toBe("STORAGE_ERROR");
|
expect(error.code).toBe("STORAGE_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be identifiable as StorageError", () => {
|
it("should be identifiable as StorageError", () => {
|
||||||
const error = new StorageError("Test");
|
const error = new StorageError("Test");
|
||||||
|
|
||||||
expect(error instanceof StorageError).toBe(true);
|
expect(error instanceof StorageError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ErrorType union", () => {
|
describe("ErrorType union", () => {
|
||||||
it("should accept all error types", () => {
|
it("should accept all error types", () => {
|
||||||
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
||||||
new AppError("Test", "TEST"),
|
new AppError("Test", "TEST"),
|
||||||
new ImageError("Test"),
|
new ImageError("Test"),
|
||||||
new TextError("Test"),
|
new TextError("Test"),
|
||||||
new CanvasError("Test"),
|
new CanvasError("Test"),
|
||||||
new StorageError("Test"),
|
new StorageError("Test"),
|
||||||
];
|
];
|
||||||
|
|
||||||
errors.forEach((error) => {
|
errors.forEach((error) => {
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow type narrowing with instanceof", () => {
|
it("should allow type narrowing with instanceof", () => {
|
||||||
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
|
||||||
new ImageError("Test"),
|
new ImageError("Test"),
|
||||||
new TextError("Test"),
|
new TextError("Test"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const imageErrors = errors.filter((e) => e instanceof ImageError);
|
const imageErrors = errors.filter((e) => e instanceof ImageError);
|
||||||
const textErrors = errors.filter((e) => e instanceof TextError);
|
const textErrors = errors.filter((e) => e instanceof TextError);
|
||||||
|
|
||||||
expect(imageErrors).toHaveLength(1);
|
expect(imageErrors).toHaveLength(1);
|
||||||
expect(textErrors).toHaveLength(1);
|
expect(textErrors).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Error handling patterns", () => {
|
describe("Error handling patterns", () => {
|
||||||
it("should handle errors in try-catch blocks", () => {
|
it("should handle errors in try-catch blocks", () => {
|
||||||
let caughtError: AppError | null = null;
|
let caughtError: AppError | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
throw new ImageError("Image failed to load");
|
throw new ImageError("Image failed to load");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
caughtError = error;
|
caughtError = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(caughtError).not.toBeNull();
|
expect(caughtError).not.toBeNull();
|
||||||
expect(caughtError?.code).toBe("IMAGE_ERROR");
|
expect(caughtError?.code).toBe("IMAGE_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should preserve error details through error handling", () => {
|
it("should preserve error details through error handling", () => {
|
||||||
const originalError = new AppError("Test", "TEST", { id: 123 });
|
const originalError = new AppError("Test", "TEST", { id: 123 });
|
||||||
let caughtError: AppError | null = null;
|
let caughtError: AppError | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
throw originalError;
|
throw originalError;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
caughtError = error;
|
caughtError = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(caughtError?.details).toEqual({ id: 123 });
|
expect(caughtError?.details).toEqual({ id: 123 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+155
-155
@@ -1,155 +1,155 @@
|
|||||||
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
|
||||||
import { createError, formatError, logError } from "$lib/utils/errorUtils";
|
import { createError, formatError, logError } from "$lib/utils/errorUtils";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
describe("errorUtils", () => {
|
describe("errorUtils", () => {
|
||||||
describe("formatError", () => {
|
describe("formatError", () => {
|
||||||
it("should format AppError", () => {
|
it("should format AppError", () => {
|
||||||
const error = new AppError("Test error", "TEST_CODE");
|
const error = new AppError("Test error", "TEST_CODE");
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Test error");
|
expect(result).toBe("Default message: Test error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format Error", () => {
|
it("should format Error", () => {
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Test error");
|
expect(result).toBe("Default message: Test error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format string error", () => {
|
it("should format string error", () => {
|
||||||
const error = "String error message";
|
const error = "String error message";
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: String error message");
|
expect(result).toBe("Default message: String error message");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format unknown error", () => {
|
it("should format unknown error", () => {
|
||||||
const error = { custom: "object" };
|
const error = { custom: "object" };
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Произошла неизвестная ошибка");
|
expect(result).toBe("Default message: Произошла неизвестная ошибка");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default message when not provided", () => {
|
it("should use default message when not provided", () => {
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
const result = formatError(error);
|
const result = formatError(error);
|
||||||
|
|
||||||
expect(result).toBe("Произошла ошибка: Test error");
|
expect(result).toBe("Произошла ошибка: Test error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format ImageError", () => {
|
it("should format ImageError", () => {
|
||||||
const error = new ImageError("Image failed");
|
const error = new ImageError("Image failed");
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Image failed");
|
expect(result).toBe("Default message: Image failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format TextError", () => {
|
it("should format TextError", () => {
|
||||||
const error = new TextError("Text failed");
|
const error = new TextError("Text failed");
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Text failed");
|
expect(result).toBe("Default message: Text failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format CanvasError", () => {
|
it("should format CanvasError", () => {
|
||||||
const error = new CanvasError("Canvas failed");
|
const error = new CanvasError("Canvas failed");
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Canvas failed");
|
expect(result).toBe("Default message: Canvas failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format StorageError", () => {
|
it("should format StorageError", () => {
|
||||||
const error = new StorageError("Storage failed");
|
const error = new StorageError("Storage failed");
|
||||||
const result = formatError(error, "Default message");
|
const result = formatError(error, "Default message");
|
||||||
|
|
||||||
expect(result).toBe("Default message: Storage failed");
|
expect(result).toBe("Default message: Storage failed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createError", () => {
|
describe("createError", () => {
|
||||||
it("should create AppError with message and code", () => {
|
it("should create AppError with message and code", () => {
|
||||||
const error = createError("Test error", "TEST_CODE");
|
const error = createError("Test error", "TEST_CODE");
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AppError);
|
expect(error).toBeInstanceOf(AppError);
|
||||||
expect(error.message).toBe("Test error");
|
expect(error.message).toBe("Test error");
|
||||||
expect(error.code).toBe("TEST_CODE");
|
expect(error.code).toBe("TEST_CODE");
|
||||||
expect(error.details).toBeUndefined();
|
expect(error.details).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create AppError with message, code and details", () => {
|
it("should create AppError with message, code and details", () => {
|
||||||
const details = { userId: 123, action: "test" };
|
const details = { userId: 123, action: "test" };
|
||||||
const error = createError("Test error", "TEST_CODE", details);
|
const error = createError("Test error", "TEST_CODE", details);
|
||||||
|
|
||||||
expect(error.details).toEqual(details);
|
expect(error.details).toEqual(details);
|
||||||
expect(error.message).toBe("Test error");
|
expect(error.message).toBe("Test error");
|
||||||
expect(error.code).toBe("TEST_CODE");
|
expect(error.code).toBe("TEST_CODE");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("logError", () => {
|
describe("logError", () => {
|
||||||
it("should log Error with stack", () => {
|
it("should log Error with stack", () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
|
|
||||||
logError(error, "Test context");
|
logError(error, "Test context");
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||||
error,
|
error,
|
||||||
context: "Test context",
|
context: "Test context",
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log AppError with stack", () => {
|
it("should log AppError with stack", () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
const error = new AppError("Test error", "TEST_CODE");
|
const error = new AppError("Test error", "TEST_CODE");
|
||||||
|
|
||||||
logError(error, "Test context");
|
logError(error, "Test context");
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||||
error,
|
error,
|
||||||
context: "Test context",
|
context: "Test context",
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log string error without stack", () => {
|
it("should log string error without stack", () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
logError("String error", "Test context");
|
logError("String error", "Test context");
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||||
error: "String error",
|
error: "String error",
|
||||||
context: "Test context",
|
context: "Test context",
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
stack: undefined,
|
stack: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log error without context", () => {
|
it("should log error without context", () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
|
|
||||||
logError(error);
|
logError(error);
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
|
||||||
error,
|
error,
|
||||||
context: undefined,
|
context: undefined,
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Layout from "$routes/+layout.svelte";
|
import Layout from "$routes/+layout.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<span data-testid="test-child">Hello</span>
|
<span data-testid="test-child">Hello</span>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import Page from "$routes/+page.svelte";
|
import Page from "$routes/+page.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page />
|
<Page />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { prerender, ssr } from "$routes/+layout";
|
import { prerender, ssr } from "$routes/+layout";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("+layout.ts", () => {
|
describe("+layout.ts", () => {
|
||||||
it("should export ssr as false", () => {
|
it("should export ssr as false", () => {
|
||||||
expect(ssr).toBe(false);
|
expect(ssr).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should export prerender as true", () => {
|
it("should export prerender as true", () => {
|
||||||
expect(prerender).toBe(true);
|
expect(prerender).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
import { themeState } from "$states/theme.svelte";
|
import { themeState } from "$states/theme.svelte";
|
||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import LayoutTest from "./LayoutTest.svelte";
|
import LayoutTest from "./LayoutTest.svelte";
|
||||||
|
|
||||||
describe("+layout.svelte", () => {
|
describe("+layout.svelte", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
themeState.current = "dark";
|
themeState.current = "dark";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply dark theme", () => {
|
it("should apply dark theme", () => {
|
||||||
themeState.current = "dark";
|
themeState.current = "dark";
|
||||||
|
|
||||||
expect(themeState.current).toBe("dark");
|
expect(themeState.current).toBe("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply light theme", () => {
|
it("should apply light theme", () => {
|
||||||
themeState.current = "light";
|
themeState.current = "light";
|
||||||
|
|
||||||
expect(themeState.current).toBe("light");
|
expect(themeState.current).toBe("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle theme", () => {
|
it("should toggle theme", () => {
|
||||||
themeState.current = "dark";
|
themeState.current = "dark";
|
||||||
themeState.toggle();
|
themeState.toggle();
|
||||||
|
|
||||||
expect(themeState.current).toBe("light");
|
expect(themeState.current).toBe("light");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Layout Component Coverage", () => {
|
describe("Layout Component Coverage", () => {
|
||||||
it("should render children snippet and initialize props", () => {
|
it("should render children snippet and initialize props", () => {
|
||||||
render(LayoutTest);
|
render(LayoutTest);
|
||||||
|
|
||||||
expect(screen.getByTestId("test-child")).toBeInTheDocument();
|
expect(screen.getByTestId("test-child")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { render, screen } from "@testing-library/svelte";
|
import { render, screen } from "@testing-library/svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import PageTest from "./PageTest.svelte";
|
import PageTest from "./PageTest.svelte";
|
||||||
|
|
||||||
describe("+page.svelte", () => {
|
describe("+page.svelte", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
render(PageTest);
|
render(PageTest);
|
||||||
expect(screen.getByText("Тексты панелей")).toBeInTheDocument();
|
expect(screen.getByText("Тексты панелей")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,125 +1,131 @@
|
|||||||
import { DownloadService, type DownloadItem } from "$services/downloadService";
|
import { DownloadService, type DownloadItem } from "$services/downloadService";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { Stage } from "konva/lib/Stage";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
vi.mock("file-saver", () => {
|
|
||||||
return {
|
vi.mock("file-saver", () => {
|
||||||
saveAs: vi.fn(),
|
return {
|
||||||
};
|
saveAs: vi.fn(),
|
||||||
});
|
};
|
||||||
|
});
|
||||||
vi.mock("jszip", () => {
|
|
||||||
const mockZipInstance = {
|
vi.mock("jszip", () => {
|
||||||
file: vi.fn().mockReturnThis(),
|
const mockZipInstance = {
|
||||||
generateAsync: vi.fn().mockResolvedValue(new Blob([])),
|
file: vi.fn().mockReturnThis(),
|
||||||
};
|
generateAsync: vi.fn().mockResolvedValue(new Blob([])),
|
||||||
|
};
|
||||||
return {
|
|
||||||
default: vi.fn(function () {
|
return {
|
||||||
return mockZipInstance;
|
default: vi.fn(function () {
|
||||||
}),
|
return mockZipInstance;
|
||||||
};
|
}),
|
||||||
});
|
};
|
||||||
|
});
|
||||||
describe("DownloadService", () => {
|
|
||||||
let service: DownloadService;
|
describe("DownloadService", () => {
|
||||||
let mockKonvaStage: any;
|
let service: DownloadService;
|
||||||
|
let mockKonvaStage: Stage;
|
||||||
beforeEach(() => {
|
|
||||||
service = new DownloadService();
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
service = new DownloadService();
|
||||||
|
vi.clearAllMocks();
|
||||||
mockKonvaStage = {
|
|
||||||
toBlob: vi.fn(({ callback }) => {
|
mockKonvaStage = {
|
||||||
callback(new Blob(["test image data"], { type: "image/png" }));
|
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 () => {
|
describe("downloadPanel", () => {
|
||||||
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1");
|
it("should export panel successfully", async () => {
|
||||||
|
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1");
|
||||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
|
||||||
|
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
expect(result.success).toBe(true);
|
||||||
expect(blobArg instanceof Blob).toBe(true);
|
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
||||||
expect(fileNameArg).toBe("test-panel-1.png");
|
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 };
|
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");
|
|
||||||
|
const result = await service.downloadPanel(
|
||||||
expect(result.success).toBe(false);
|
invalidStage as unknown as Stage,
|
||||||
if (!result.success) {
|
"test-panel-1.png",
|
||||||
expect(result.error).toContain("Konva Stage не найден или не поддерживает toBlob");
|
);
|
||||||
}
|
|
||||||
});
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
it("should handle blob creation failure", async () => {
|
expect(result.error).toContain("Konva Stage не найден или не поддерживает toBlob");
|
||||||
mockKonvaStage.toBlob = vi.fn(({ callback }) => {
|
}
|
||||||
callback(null);
|
});
|
||||||
});
|
|
||||||
|
it("should handle blob creation failure", async () => {
|
||||||
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1.png");
|
mockKonvaStage.toBlob = vi.fn(async ({ callback }) => {
|
||||||
|
callback(null);
|
||||||
expect(result.success).toBe(false);
|
});
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toContain("Не удалось создать изображение");
|
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];
|
describe("downloadAllPanels", () => {
|
||||||
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
it("should handle successful download of multiple panels", async () => {
|
||||||
|
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: mockKonvaStage }];
|
||||||
expect(result.success).toBe(true);
|
const result = await service.downloadAll(panels);
|
||||||
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||||
expect(blobArg instanceof Blob).toBe(true);
|
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
||||||
expect(fileNameArg).toBe("panels.zip");
|
|
||||||
expect(zipInstance.file).toHaveBeenCalledTimes(1);
|
expect(result.success).toBe(true);
|
||||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
expect(mockKonvaStage.toBlob).toHaveBeenCalled();
|
||||||
});
|
expect(blobArg instanceof Blob).toBe(true);
|
||||||
|
expect(fileNameArg).toBe("panels.zip");
|
||||||
it("should add to zip all files", async () => {
|
expect(zipInstance.file).toHaveBeenCalledTimes(1);
|
||||||
const panels: Array<DownloadItem> = [
|
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
||||||
{ filename: "test-panel-1", stage: mockKonvaStage },
|
});
|
||||||
{ filename: "test-panel-2", stage: mockKonvaStage },
|
|
||||||
{ filename: "test-panel-3", stage: mockKonvaStage },
|
it("should add to zip all files", async () => {
|
||||||
{ filename: "test-panel-4", stage: mockKonvaStage },
|
const panels: Array<DownloadItem> = [
|
||||||
{ filename: "test-panel-5", stage: mockKonvaStage },
|
{ filename: "test-panel-1", stage: mockKonvaStage },
|
||||||
];
|
{ filename: "test-panel-2", stage: mockKonvaStage },
|
||||||
const result = await service.downloadAll(panels);
|
{ filename: "test-panel-3", stage: mockKonvaStage },
|
||||||
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
{ filename: "test-panel-4", stage: mockKonvaStage },
|
||||||
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
{ filename: "test-panel-5", stage: mockKonvaStage },
|
||||||
|
];
|
||||||
expect(result.success).toBe(true);
|
const result = await service.downloadAll(panels);
|
||||||
expect(mockKonvaStage.toBlob).toHaveBeenCalledTimes(5);
|
// const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
|
||||||
expect(zipInstance.file).toHaveBeenCalledTimes(5);
|
const zipInstance = vi.mocked(JSZip).mock.instances[0];
|
||||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
|
|
||||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-2.png", expect.anything());
|
expect(result.success).toBe(true);
|
||||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-3.png", expect.anything());
|
expect(mockKonvaStage.toBlob).toHaveBeenCalledTimes(5);
|
||||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-4.png", expect.anything());
|
expect(zipInstance.file).toHaveBeenCalledTimes(5);
|
||||||
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-5.png", expect.anything());
|
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());
|
||||||
it("should handle failure", async () => {
|
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-4.png", expect.anything());
|
||||||
const invalidStage = { toBlob: undefined };
|
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-5.png", expect.anything());
|
||||||
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: invalidStage as any }];
|
});
|
||||||
const result = await service.downloadAll(panels);
|
|
||||||
|
it("should handle failure", async () => {
|
||||||
expect(result.success).toBe(false);
|
const invalidStage = { toBlob: undefined };
|
||||||
if (!result.success) {
|
const panels: Array<DownloadItem> = [
|
||||||
expect(result.error).toContain("Ошибка сохранения архива");
|
{ 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 { PANEL_SETTINGS } from "$lib/constants";
|
||||||
import { imageConfigState, ImageConfigState } from "$states/imageConfig.svelte";
|
import { imageConfigState, ImageConfigState } from "$states/imageConfig.svelte";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
let lastOnload: (() => void) | null = null;
|
type ImageEventHandler = ((this: HTMLImageElement, ev?: Event) => void) | null;
|
||||||
let lastOnerror: (() => void) | null = null;
|
type ImageErrorEventHandler =
|
||||||
|
| ((
|
||||||
vi.stubGlobal(
|
this: HTMLImageElement,
|
||||||
"Image",
|
ev?: string | Event,
|
||||||
class {
|
source?: string,
|
||||||
_onload: (() => void) | null = null;
|
lineno?: number,
|
||||||
_onerror: (() => void) | null = null;
|
colno?: number,
|
||||||
_src: string = "";
|
error?: Error,
|
||||||
crossOrigin: string = "";
|
) => void)
|
||||||
|
| null;
|
||||||
set src(val: string) {
|
|
||||||
this._src = val;
|
let lastOnload: ImageEventHandler = null;
|
||||||
if (val.includes("error")) {
|
let lastOnerror: ImageErrorEventHandler = null;
|
||||||
setTimeout(() => this._onerror?.(), 1);
|
|
||||||
} else {
|
vi.stubGlobal(
|
||||||
setTimeout(() => this._onload?.(), 1);
|
"Image",
|
||||||
}
|
class {
|
||||||
}
|
_onload: ImageEventHandler = null;
|
||||||
get src() {
|
_onerror: ImageErrorEventHandler = null;
|
||||||
return this._src;
|
_src: string = "";
|
||||||
}
|
crossOrigin: string = "";
|
||||||
|
|
||||||
set onload(val: any) {
|
set src(val: string) {
|
||||||
this._onload = val;
|
this._src = val;
|
||||||
if (val) lastOnload = val;
|
if (val.includes("error")) {
|
||||||
}
|
setTimeout(
|
||||||
get onload() {
|
() => this._onerror?.call(this as unknown as HTMLImageElement, new Event("error")),
|
||||||
return this._onload;
|
1,
|
||||||
}
|
);
|
||||||
|
} else {
|
||||||
set onerror(val: any) {
|
setTimeout(
|
||||||
this._onerror = val;
|
() => this._onload?.call(this as unknown as HTMLImageElement, new Event("load")),
|
||||||
if (val) lastOnerror = val;
|
1,
|
||||||
}
|
);
|
||||||
get onerror() {
|
}
|
||||||
return this._onerror;
|
}
|
||||||
}
|
get src() {
|
||||||
},
|
return this._src;
|
||||||
);
|
}
|
||||||
|
|
||||||
describe("ImageConfigState", () => {
|
set onload(val: ImageEventHandler) {
|
||||||
beforeEach(() => {
|
this._onload = val;
|
||||||
lastOnload = null;
|
if (val) lastOnload = val;
|
||||||
lastOnerror = null;
|
}
|
||||||
imageConfigState.reset();
|
get onload() {
|
||||||
});
|
return this._onload;
|
||||||
|
}
|
||||||
it("should create new instance with default values", () => {
|
|
||||||
const newState = new ImageConfigState();
|
set onerror(val: ImageErrorEventHandler) {
|
||||||
expect(newState.image).toBeUndefined();
|
this._onerror = val;
|
||||||
expect(newState.imageLink).toBe("");
|
if (val) lastOnerror = val;
|
||||||
expect(newState.imageReady).toBe(false);
|
}
|
||||||
expect(newState.cropLeft).toBe(0);
|
get onerror() {
|
||||||
expect(newState.cropTop).toBe(0);
|
return this._onerror;
|
||||||
expect(newState.cropRight).toBe(0);
|
}
|
||||||
expect(newState.cropBottom).toBe(0);
|
},
|
||||||
newState.destroy();
|
);
|
||||||
});
|
|
||||||
|
describe("ImageConfigState", () => {
|
||||||
it("should initialize with default background image", async () => {
|
beforeEach(() => {
|
||||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
lastOnload = null;
|
||||||
|
lastOnerror = null;
|
||||||
expect(imageConfigState.imageReady).toBe(true);
|
imageConfigState.reset();
|
||||||
expect(imageConfigState.image).toBeDefined();
|
});
|
||||||
expect(imageConfigState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
|
||||||
});
|
it("should create new instance with default values", () => {
|
||||||
|
const newState = new ImageConfigState();
|
||||||
it("should handle manual image upload correctly", async () => {
|
expect(newState.image).toBeUndefined();
|
||||||
const testLink = "https://example.com/test.png";
|
expect(newState.imageLink).toBe("");
|
||||||
const uploadPromise = imageConfigState.uploadImageByLink(testLink);
|
expect(newState.imageReady).toBe(false);
|
||||||
|
expect(newState.cropLeft).toBe(0);
|
||||||
expect(imageConfigState.imageReady).toBe(false);
|
expect(newState.cropTop).toBe(0);
|
||||||
|
expect(newState.cropRight).toBe(0);
|
||||||
await uploadPromise;
|
expect(newState.cropBottom).toBe(0);
|
||||||
expect(imageConfigState.imageReady).toBe(true);
|
newState.destroy();
|
||||||
expect(imageConfigState.imageLink).toBe(testLink);
|
});
|
||||||
});
|
|
||||||
|
it("should initialize with default background image", async () => {
|
||||||
it("should reset state to defaults", async () => {
|
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||||
await imageConfigState.uploadImageByLink("some-image.png");
|
|
||||||
imageConfigState.cropLeft = 100;
|
expect(imageConfigState.imageReady).toBe(true);
|
||||||
|
expect(imageConfigState.image).toBeDefined();
|
||||||
imageConfigState.reset();
|
expect(imageConfigState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||||
|
});
|
||||||
expect(imageConfigState.imageReady).toBe(false);
|
|
||||||
expect(imageConfigState.imageLink).toBe("");
|
it("should handle manual image upload correctly", async () => {
|
||||||
expect(imageConfigState.cropLeft).toBe(0);
|
const testLink = "https://example.com/test.png";
|
||||||
expect(imageConfigState.image).toBeUndefined();
|
const uploadPromise = imageConfigState.uploadImageByLink(testLink);
|
||||||
});
|
|
||||||
|
expect(imageConfigState.imageReady).toBe(false);
|
||||||
it("should handle image loading error", async () => {
|
|
||||||
await expect(imageConfigState.uploadImageByLink("error-link")).rejects.toThrow("Failed to load image");
|
await uploadPromise;
|
||||||
|
expect(imageConfigState.imageReady).toBe(true);
|
||||||
expect(imageConfigState.imageReady).toBe(false);
|
expect(imageConfigState.imageLink).toBe(testLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should abort previous upload when new upload starts", async () => {
|
it("should reset state to defaults", async () => {
|
||||||
const upload1 = imageConfigState.uploadImageByLink("test1.jpg");
|
await imageConfigState.uploadImageByLink("some-image.png");
|
||||||
const upload2 = imageConfigState.uploadImageByLink("test2.jpg");
|
imageConfigState.cropLeft = 100;
|
||||||
|
|
||||||
await expect(upload1).rejects.toThrow("Aborted");
|
imageConfigState.reset();
|
||||||
await expect(upload2).resolves.toBeUndefined();
|
|
||||||
|
expect(imageConfigState.imageReady).toBe(false);
|
||||||
expect(imageConfigState.imageLink).toBe("test2.jpg");
|
expect(imageConfigState.imageLink).toBe("");
|
||||||
});
|
expect(imageConfigState.cropLeft).toBe(0);
|
||||||
|
expect(imageConfigState.image).toBeUndefined();
|
||||||
it("should cleanup previous image before loading new one", async () => {
|
});
|
||||||
await imageConfigState.uploadImageByLink("test1.jpg");
|
|
||||||
const firstImage = imageConfigState.image;
|
it("should handle image loading error", async () => {
|
||||||
|
await expect(imageConfigState.uploadImageByLink("error-link")).rejects.toThrow(
|
||||||
await imageConfigState.uploadImageByLink("test2.jpg");
|
"Failed to load image",
|
||||||
|
);
|
||||||
expect(firstImage?.onload).toBeNull();
|
|
||||||
expect(firstImage?.onerror).toBeNull();
|
expect(imageConfigState.imageReady).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set crop values", () => {
|
it("should abort previous upload when new upload starts", async () => {
|
||||||
imageConfigState.cropLeft = 10;
|
const upload1 = imageConfigState.uploadImageByLink("test1.jpg");
|
||||||
imageConfigState.cropTop = 20;
|
const upload2 = imageConfigState.uploadImageByLink("test2.jpg");
|
||||||
imageConfigState.cropRight = 30;
|
|
||||||
imageConfigState.cropBottom = 40;
|
await expect(upload1).rejects.toThrow("Aborted");
|
||||||
|
await expect(upload2).resolves.toBeUndefined();
|
||||||
expect(imageConfigState.cropLeft).toBe(10);
|
|
||||||
expect(imageConfigState.cropTop).toBe(20);
|
expect(imageConfigState.imageLink).toBe("test2.jpg");
|
||||||
expect(imageConfigState.cropRight).toBe(30);
|
});
|
||||||
expect(imageConfigState.cropBottom).toBe(40);
|
|
||||||
});
|
it("should cleanup previous image before loading new one", async () => {
|
||||||
|
await imageConfigState.uploadImageByLink("test1.jpg");
|
||||||
it("should cleanup image event handlers on reset", async () => {
|
const firstImage = imageConfigState.image;
|
||||||
await imageConfigState.uploadImageByLink("test.jpg");
|
|
||||||
const img = imageConfigState.image;
|
await imageConfigState.uploadImageByLink("test2.jpg");
|
||||||
|
|
||||||
imageConfigState.reset();
|
expect(firstImage?.onload).toBeNull();
|
||||||
|
expect(firstImage?.onerror).toBeNull();
|
||||||
expect(img?.onload).toBeNull();
|
});
|
||||||
expect(img?.onerror).toBeNull();
|
|
||||||
});
|
it("should set crop values", () => {
|
||||||
|
imageConfigState.cropLeft = 10;
|
||||||
it("should abort ongoing upload on reset", async () => {
|
imageConfigState.cropTop = 20;
|
||||||
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
imageConfigState.cropRight = 30;
|
||||||
|
imageConfigState.cropBottom = 40;
|
||||||
imageConfigState.reset();
|
|
||||||
|
expect(imageConfigState.cropLeft).toBe(10);
|
||||||
await expect(upload).rejects.toThrow("Aborted");
|
expect(imageConfigState.cropTop).toBe(20);
|
||||||
});
|
expect(imageConfigState.cropRight).toBe(30);
|
||||||
|
expect(imageConfigState.cropBottom).toBe(40);
|
||||||
it("should cleanup resources on destroy", async () => {
|
});
|
||||||
await imageConfigState.uploadImageByLink("test.jpg");
|
|
||||||
const img = imageConfigState.image;
|
it("should cleanup image event handlers on reset", async () => {
|
||||||
|
await imageConfigState.uploadImageByLink("test.jpg");
|
||||||
imageConfigState.destroy();
|
const img = imageConfigState.image;
|
||||||
|
|
||||||
expect(imageConfigState.image).toBeUndefined();
|
imageConfigState.reset();
|
||||||
expect(img?.onload).toBeNull();
|
|
||||||
expect(img?.onerror).toBeNull();
|
expect(img?.onload).toBeNull();
|
||||||
});
|
expect(img?.onerror).toBeNull();
|
||||||
|
});
|
||||||
it("should abort ongoing upload on destroy", async () => {
|
|
||||||
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
it("should abort ongoing upload on reset", async () => {
|
||||||
|
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
||||||
imageConfigState.destroy();
|
|
||||||
|
imageConfigState.reset();
|
||||||
await expect(upload).rejects.toThrow("Aborted");
|
|
||||||
});
|
await expect(upload).rejects.toThrow("Aborted");
|
||||||
|
});
|
||||||
it("should cover aborted onload branch", async () => {
|
|
||||||
const promise = imageConfigState.uploadImageByLink("test.png");
|
it("should cleanup resources on destroy", async () => {
|
||||||
|
await imageConfigState.uploadImageByLink("test.jpg");
|
||||||
imageConfigState.destroy();
|
const img = imageConfigState.image;
|
||||||
|
|
||||||
if (lastOnload) lastOnload();
|
imageConfigState.destroy();
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow();
|
expect(imageConfigState.image).toBeUndefined();
|
||||||
expect(imageConfigState.imageReady).toBe(false);
|
expect(img?.onload).toBeNull();
|
||||||
});
|
expect(img?.onerror).toBeNull();
|
||||||
|
});
|
||||||
it("should cover aborted onerror branch", async () => {
|
|
||||||
const promise = imageConfigState.uploadImageByLink("test.png");
|
it("should abort ongoing upload on destroy", async () => {
|
||||||
|
const upload = imageConfigState.uploadImageByLink("test.jpg");
|
||||||
imageConfigState.destroy();
|
|
||||||
|
imageConfigState.destroy();
|
||||||
if (lastOnerror) lastOnerror();
|
|
||||||
|
await expect(upload).rejects.toThrow("Aborted");
|
||||||
await expect(promise).rejects.toThrow();
|
});
|
||||||
expect(imageConfigState.imageReady).toBe(false);
|
|
||||||
});
|
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 { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("konvaAllStages.svelte", () => {
|
describe("konvaAllStages.svelte", () => {
|
||||||
it("should import successfully", () => {
|
it("should import successfully", () => {
|
||||||
expect(konvaAllStagesState).toBeDefined();
|
expect(konvaAllStagesState).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,54 +1,14 @@
|
|||||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import type { Stage } from "svelte-konva";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
describe("konvaStage.svelte", () => {
|
|
||||||
describe("initial state", () => {
|
describe("konvaStageState integration", () => {
|
||||||
it("should have undefined stage initially", () => {
|
it("should work", () => {
|
||||||
expect(konvaStageState.stage).toBeUndefined();
|
expect(konvaStageState.stage).toBeUndefined();
|
||||||
});
|
|
||||||
|
const mock = { name: "stage" } as unknown as Stage;
|
||||||
it("should have stage getter", () => {
|
konvaStageState.stage = mock;
|
||||||
expect(konvaStageState).toHaveProperty("stage");
|
|
||||||
});
|
expect(konvaStageState.stage).toStrictEqual(mock);
|
||||||
|
});
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,158 +1,167 @@
|
|||||||
import { STATE_DATA, withPersistence } from "$states/persisted.svelte";
|
import { STATE_DATA, withPersistence } from "$states/persisted.svelte";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const localStorageMock = (() => {
|
const localStorageMock = (() => {
|
||||||
let store: Record<string, string> = {};
|
let store: Record<string, string> = {};
|
||||||
return {
|
return {
|
||||||
getItem: vi.fn((key: string) => store[key] || null),
|
getItem: vi.fn((key: string) => store[key] || null),
|
||||||
setItem: vi.fn((key: string, value: string) => {
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
store[key] = value;
|
store[key] = value;
|
||||||
}),
|
}),
|
||||||
removeItem: vi.fn((key: string) => {
|
removeItem: vi.fn((key: string) => {
|
||||||
delete store[key];
|
delete store[key];
|
||||||
}),
|
}),
|
||||||
clear: vi.fn(() => {
|
clear: vi.fn(() => {
|
||||||
store = {};
|
store = {};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "localStorage", {
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("persisted.svelte", () => {
|
describe("persisted.svelte", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorageMock.clear();
|
localStorageMock.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("withPersistence", () => {
|
describe("withPersistence", () => {
|
||||||
it("should return state unchanged when not in browser", () => {
|
it("should return state unchanged when not in browser", () => {
|
||||||
const mockState = {
|
const mockState = {
|
||||||
[STATE_DATA]: { test: "value" },
|
[STATE_DATA]: { test: "value" },
|
||||||
};
|
};
|
||||||
const result = withPersistence("test-key", mockState);
|
const result = withPersistence("test-key", mockState);
|
||||||
expect(result).toBe(mockState);
|
expect(result).toBe(mockState);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should restore state from localStorage when valid JSON exists", () => {
|
it("should restore state from localStorage when valid JSON exists", () => {
|
||||||
const savedData = { value: 42, name: "test" };
|
const savedData = { value: 42, name: "test" };
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
[STATE_DATA]: { value: 0, name: "" },
|
[STATE_DATA]: { value: 0, name: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
withPersistence("test-key", state);
|
withPersistence("test-key", state);
|
||||||
|
|
||||||
expect(state[STATE_DATA]).toEqual(savedData);
|
expect(state[STATE_DATA]).toEqual(savedData);
|
||||||
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
|
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle corrupted JSON in localStorage gracefully", () => {
|
it("should handle corrupted JSON in localStorage gracefully", () => {
|
||||||
localStorageMock.getItem.mockReturnValue("{ invalid json");
|
localStorageMock.getItem.mockReturnValue("{ invalid json");
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
[STATE_DATA]: { value: 0 },
|
[STATE_DATA]: { value: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => withPersistence("test-key", state)).not.toThrow();
|
expect(() => withPersistence("test-key", state)).not.toThrow();
|
||||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty localStorage (no saved data)", () => {
|
it("should handle empty localStorage (no saved data)", () => {
|
||||||
localStorageMock.getItem.mockReturnValue(null);
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
[STATE_DATA]: { value: 0 },
|
[STATE_DATA]: { value: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
withPersistence("test-key", state);
|
withPersistence("test-key", state);
|
||||||
|
|
||||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should save state to localStorage with debounce", async () => {
|
it("should save state to localStorage with debounce", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const state = {
|
const state = {
|
||||||
[STATE_DATA]: { count: 1 },
|
[STATE_DATA]: { count: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
withPersistence("test-key", state, 500);
|
withPersistence("test-key", state, 500);
|
||||||
|
|
||||||
state[STATE_DATA] = { count: 2 };
|
state[STATE_DATA] = { count: 2 };
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
"test-key",
|
||||||
vi.useRealTimers();
|
JSON.stringify({ count: 2 }),
|
||||||
});
|
);
|
||||||
|
|
||||||
it("should save state immediately when debounce is 0", async () => {
|
vi.useRealTimers();
|
||||||
const state = {
|
});
|
||||||
[STATE_DATA]: { count: 1 },
|
|
||||||
};
|
it("should save state immediately when debounce is 0", async () => {
|
||||||
|
const state = {
|
||||||
withPersistence("test-key", state, 0);
|
[STATE_DATA]: { count: 1 },
|
||||||
|
};
|
||||||
state[STATE_DATA] = { count: 2 };
|
|
||||||
|
withPersistence("test-key", state, 0);
|
||||||
await Promise.resolve();
|
|
||||||
|
state[STATE_DATA] = { count: 2 };
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
|
|
||||||
});
|
await Promise.resolve();
|
||||||
|
|
||||||
it("should cleanup timeout on state change during debounce", async () => {
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
vi.useFakeTimers();
|
"test-key",
|
||||||
const data = $state({ count: 1 });
|
JSON.stringify({ count: 2 }),
|
||||||
|
);
|
||||||
const state = {
|
});
|
||||||
get [STATE_DATA]() {
|
|
||||||
return data;
|
it("should cleanup timeout on state change during debounce", async () => {
|
||||||
},
|
vi.useFakeTimers();
|
||||||
set [STATE_DATA](v) {
|
const data = $state({ count: 1 });
|
||||||
data.count = v.count;
|
|
||||||
},
|
const state = {
|
||||||
};
|
get [STATE_DATA]() {
|
||||||
|
return data;
|
||||||
withPersistence("test-key", state, 500);
|
},
|
||||||
|
set [STATE_DATA](v) {
|
||||||
state[STATE_DATA] = { count: 2 };
|
data.count = v.count;
|
||||||
await Promise.resolve();
|
},
|
||||||
|
};
|
||||||
state[STATE_DATA] = { count: 3 };
|
|
||||||
await Promise.resolve();
|
withPersistence("test-key", state, 500);
|
||||||
|
|
||||||
vi.runOnlyPendingTimers();
|
state[STATE_DATA] = { count: 2 };
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
|
state[STATE_DATA] = { count: 3 };
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 3 }));
|
await Promise.resolve();
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.runOnlyPendingTimers();
|
||||||
});
|
await Promise.resolve();
|
||||||
|
|
||||||
it("should use DEBOUNCE_DURATION constant as default", async () => {
|
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
|
||||||
const state = {
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
[STATE_DATA]: { test: true },
|
"test-key",
|
||||||
};
|
JSON.stringify({ count: 3 }),
|
||||||
|
);
|
||||||
withPersistence("test-key", state);
|
|
||||||
|
vi.useRealTimers();
|
||||||
state[STATE_DATA] = { test: false };
|
});
|
||||||
|
|
||||||
await Promise.resolve();
|
it("should use DEBOUNCE_DURATION constant as default", async () => {
|
||||||
|
const state = {
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
[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 { TextAlign } from "$lib/constants";
|
||||||
import type { HexColor } from "$lib/types";
|
import type { HexColor } from "$lib/types";
|
||||||
import { STATE_DATA } from "$states/persisted.svelte";
|
import { STATE_DATA } from "$states/persisted.svelte";
|
||||||
import { textConfigState } from "$states/textConfig.svelte";
|
import { textConfigState } from "$states/textConfig.svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const TEXT_ALIGN_VALUES = Object.values(TextAlign);
|
const TEXT_ALIGN_VALUES = Object.values(TextAlign);
|
||||||
|
|
||||||
describe("textConfig.svelte", () => {
|
describe("textConfig.svelte", () => {
|
||||||
describe("initial state", () => {
|
describe("initial state", () => {
|
||||||
it("should have valid initial state structure", () => {
|
it("should have valid initial state structure", () => {
|
||||||
expect(typeof textConfigState.fontSize).toBe("number");
|
expect(typeof textConfigState.fontSize).toBe("number");
|
||||||
expect(typeof textConfigState.fontFamily).toBe("string");
|
expect(typeof textConfigState.fontFamily).toBe("string");
|
||||||
expect(typeof textConfigState.color).toBe("string");
|
expect(typeof textConfigState.color).toBe("string");
|
||||||
expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||||
expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align);
|
expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align);
|
||||||
expect(typeof textConfigState.paddingX).toBe("number");
|
expect(typeof textConfigState.paddingX).toBe("number");
|
||||||
expect(typeof textConfigState.offsetY).toBe("number");
|
expect(typeof textConfigState.offsetY).toBe("number");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("property setters", () => {
|
describe("property setters", () => {
|
||||||
it("should update all properties correctly", () => {
|
it("should update all properties correctly", () => {
|
||||||
textConfigState.fontSize = 32;
|
textConfigState.fontSize = 32;
|
||||||
textConfigState.fontFamily = "Roboto";
|
textConfigState.fontFamily = "Roboto";
|
||||||
textConfigState.color = "#ff0000" as HexColor;
|
textConfigState.color = "#ff0000" as HexColor;
|
||||||
textConfigState.align = TextAlign.LEFT;
|
textConfigState.align = TextAlign.LEFT;
|
||||||
textConfigState.paddingX = 20;
|
textConfigState.paddingX = 20;
|
||||||
textConfigState.offsetY = -10;
|
textConfigState.offsetY = -10;
|
||||||
|
|
||||||
expect(textConfigState.fontSize).toBe(32);
|
expect(textConfigState.fontSize).toBe(32);
|
||||||
expect(textConfigState.fontFamily).toBe("Roboto");
|
expect(textConfigState.fontFamily).toBe("Roboto");
|
||||||
expect(textConfigState.color).toBe("#ff0000");
|
expect(textConfigState.color).toBe("#ff0000");
|
||||||
expect(textConfigState.align).toBe(TextAlign.LEFT);
|
expect(textConfigState.align).toBe(TextAlign.LEFT);
|
||||||
expect(textConfigState.paddingX).toBe(20);
|
expect(textConfigState.paddingX).toBe(20);
|
||||||
expect(textConfigState.offsetY).toBe(-10);
|
expect(textConfigState.offsetY).toBe(-10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("STATE_DATA", () => {
|
describe("STATE_DATA", () => {
|
||||||
it("should serialize and deserialize state", () => {
|
it("should serialize and deserialize state", () => {
|
||||||
// Set custom values
|
// Set custom values
|
||||||
textConfigState.fontSize = 18;
|
textConfigState.fontSize = 18;
|
||||||
textConfigState.fontFamily = "Georgia";
|
textConfigState.fontFamily = "Georgia";
|
||||||
textConfigState.color = "#00ff00" as HexColor;
|
textConfigState.color = "#00ff00" as HexColor;
|
||||||
textConfigState.align = TextAlign.RIGHT;
|
textConfigState.align = TextAlign.RIGHT;
|
||||||
textConfigState.paddingX = 5;
|
textConfigState.paddingX = 5;
|
||||||
textConfigState.offsetY = 15;
|
textConfigState.offsetY = 15;
|
||||||
|
|
||||||
// Get serialized data
|
// Get serialized data
|
||||||
const data = textConfigState[STATE_DATA];
|
const data = textConfigState[STATE_DATA];
|
||||||
expect(data).toEqual({
|
expect(data).toEqual({
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontFamily: "Georgia",
|
fontFamily: "Georgia",
|
||||||
color: "#00ff00",
|
color: "#00ff00",
|
||||||
align: TextAlign.RIGHT,
|
align: TextAlign.RIGHT,
|
||||||
paddingX: 5,
|
paddingX: 5,
|
||||||
offsetY: 15,
|
offsetY: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore from serialized data
|
// Restore from serialized data
|
||||||
textConfigState[STATE_DATA] = {
|
textConfigState[STATE_DATA] = {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontFamily: "Arial",
|
fontFamily: "Arial",
|
||||||
color: "#ffffff" as HexColor,
|
color: "#ffffff" as HexColor,
|
||||||
align: TextAlign.CENTER,
|
align: TextAlign.CENTER,
|
||||||
paddingX: 10,
|
paddingX: 10,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(textConfigState.fontSize).toBe(24);
|
expect(textConfigState.fontSize).toBe(24);
|
||||||
expect(textConfigState.fontFamily).toBe("Arial");
|
expect(textConfigState.fontFamily).toBe("Arial");
|
||||||
expect(textConfigState.color).toBe("#ffffff");
|
expect(textConfigState.color).toBe("#ffffff");
|
||||||
expect(textConfigState.align).toBe(TextAlign.CENTER);
|
expect(textConfigState.align).toBe(TextAlign.CENTER);
|
||||||
expect(textConfigState.paddingX).toBe(10);
|
expect(textConfigState.paddingX).toBe(10);
|
||||||
expect(textConfigState.offsetY).toBe(0);
|
expect(textConfigState.offsetY).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,95 +1,95 @@
|
|||||||
import { STATE_DATA } from "$states/persisted.svelte";
|
import { STATE_DATA } from "$states/persisted.svelte";
|
||||||
import { createState } from "$states/texts.svelte";
|
import { createState } from "$states/texts.svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("texts.svelte", () => {
|
describe("texts.svelte", () => {
|
||||||
describe("addText", () => {
|
describe("addText", () => {
|
||||||
it("should add new text with unique ID", () => {
|
it("should add new text with unique ID", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
const initialLength = state.texts.length;
|
const initialLength = state.texts.length;
|
||||||
|
|
||||||
state.addText("Test text");
|
state.addText("Test text");
|
||||||
|
|
||||||
expect(state.texts).toHaveLength(initialLength + 1);
|
expect(state.texts).toHaveLength(initialLength + 1);
|
||||||
expect(state.texts[initialLength].text).toBe("Test text");
|
expect(state.texts[initialLength].text).toBe("Test text");
|
||||||
expect(state.texts[initialLength].id).toBeDefined();
|
expect(state.texts[initialLength].id).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not add empty text", () => {
|
it("should not add empty text", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
const initialLength = state.texts.length;
|
const initialLength = state.texts.length;
|
||||||
|
|
||||||
state.addText("");
|
state.addText("");
|
||||||
state.addText(" ");
|
state.addText(" ");
|
||||||
|
|
||||||
expect(state.texts).toHaveLength(initialLength);
|
expect(state.texts).toHaveLength(initialLength);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removeText", () => {
|
describe("removeText", () => {
|
||||||
it("should remove text by ID", () => {
|
it("should remove text by ID", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
const idToRemove = state.texts[0].id;
|
const idToRemove = state.texts[0].id;
|
||||||
const initialLength = state.texts.length;
|
const initialLength = state.texts.length;
|
||||||
|
|
||||||
state.removeText(idToRemove);
|
state.removeText(idToRemove);
|
||||||
|
|
||||||
expect(state.texts).toHaveLength(initialLength - 1);
|
expect(state.texts).toHaveLength(initialLength - 1);
|
||||||
expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined();
|
expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not affect other texts when removing", () => {
|
it("should not affect other texts when removing", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
|
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
|
||||||
const idToRemove = state.texts[0].id;
|
const idToRemove = state.texts[0].id;
|
||||||
|
|
||||||
state.removeText(idToRemove);
|
state.removeText(idToRemove);
|
||||||
|
|
||||||
expect(state.texts).toStrictEqual(remainingTexts);
|
expect(state.texts).toStrictEqual(remainingTexts);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clear", () => {
|
describe("clear", () => {
|
||||||
it("should remove all texts", () => {
|
it("should remove all texts", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
state.addText("Test 1");
|
state.addText("Test 1");
|
||||||
state.addText("Test 2");
|
state.addText("Test 2");
|
||||||
state.addText("Test 3");
|
state.addText("Test 3");
|
||||||
|
|
||||||
state.clear();
|
state.clear();
|
||||||
|
|
||||||
expect(state.texts).toHaveLength(0);
|
expect(state.texts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("STATE_DATA", () => {
|
describe("STATE_DATA", () => {
|
||||||
it("should serialize and deserialize texts", () => {
|
it("should serialize and deserialize texts", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
state.clear();
|
state.clear();
|
||||||
state.addText("First");
|
state.addText("First");
|
||||||
state.addText("Second");
|
state.addText("Second");
|
||||||
state.addText("Third");
|
state.addText("Third");
|
||||||
|
|
||||||
const data = state[STATE_DATA];
|
const data = state[STATE_DATA];
|
||||||
expect(data).toEqual(["First", "Second", "Third"]);
|
expect(data).toEqual(["First", "Second", "Third"]);
|
||||||
|
|
||||||
// Restore from serialized data
|
// Restore from serialized data
|
||||||
state[STATE_DATA] = ["New 1", "New 2"];
|
state[STATE_DATA] = ["New 1", "New 2"];
|
||||||
|
|
||||||
expect(state.texts).toHaveLength(2);
|
expect(state.texts).toHaveLength(2);
|
||||||
expect(state.texts[0].text).toBe("New 1");
|
expect(state.texts[0].text).toBe("New 1");
|
||||||
expect(state.texts[1].text).toBe("New 2");
|
expect(state.texts[1].text).toBe("New 2");
|
||||||
expect(state.texts[0].id).toBe(0);
|
expect(state.texts[0].id).toBe(0);
|
||||||
expect(state.texts[1].id).toBe(1);
|
expect(state.texts[1].id).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty array", () => {
|
it("should handle empty array", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
state.addText("Test");
|
state.addText("Test");
|
||||||
|
|
||||||
state[STATE_DATA] = [];
|
state[STATE_DATA] = [];
|
||||||
|
|
||||||
expect(state.texts).toHaveLength(0);
|
expect(state.texts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import { Theme } from "$lib/constants";
|
import { Theme } from "$lib/constants";
|
||||||
import { STATE_DATA } from "$states/persisted.svelte";
|
import { STATE_DATA } from "$states/persisted.svelte";
|
||||||
import { themeState } from "$states/theme.svelte";
|
import { themeState } from "$states/theme.svelte";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const THEME_VALUES = Object.values(Theme);
|
const THEME_VALUES = Object.values(Theme);
|
||||||
|
|
||||||
describe("theme.svelte", () => {
|
describe("theme.svelte", () => {
|
||||||
describe("initial state", () => {
|
describe("initial state", () => {
|
||||||
it("should have valid initial theme value", () => {
|
it("should have valid initial theme value", () => {
|
||||||
expect(THEME_VALUES).toContain(themeState.current);
|
expect(THEME_VALUES).toContain(themeState.current);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toggle", () => {
|
describe("toggle", () => {
|
||||||
it("should toggle between themes", () => {
|
it("should toggle between themes", () => {
|
||||||
themeState.current = Theme.LIGHT;
|
themeState.current = Theme.LIGHT;
|
||||||
themeState.toggle();
|
themeState.toggle();
|
||||||
expect(themeState.current).toBe(Theme.DARK);
|
expect(themeState.current).toBe(Theme.DARK);
|
||||||
|
|
||||||
themeState.toggle();
|
themeState.toggle();
|
||||||
expect(themeState.current).toBe(Theme.LIGHT);
|
expect(themeState.current).toBe(Theme.LIGHT);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("current setter", () => {
|
describe("current setter", () => {
|
||||||
it("should set theme correctly", () => {
|
it("should set theme correctly", () => {
|
||||||
themeState.current = Theme.DARK;
|
themeState.current = Theme.DARK;
|
||||||
expect(themeState.current).toBe(Theme.DARK);
|
expect(themeState.current).toBe(Theme.DARK);
|
||||||
|
|
||||||
themeState.current = Theme.LIGHT;
|
themeState.current = Theme.LIGHT;
|
||||||
expect(themeState.current).toBe(Theme.LIGHT);
|
expect(themeState.current).toBe(Theme.LIGHT);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("STATE_DATA", () => {
|
describe("STATE_DATA", () => {
|
||||||
it("should serialize and deserialize theme", () => {
|
it("should serialize and deserialize theme", () => {
|
||||||
themeState.current = Theme.DARK;
|
themeState.current = Theme.DARK;
|
||||||
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK });
|
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK });
|
||||||
|
|
||||||
themeState[STATE_DATA] = { current: Theme.LIGHT };
|
themeState[STATE_DATA] = { current: Theme.LIGHT };
|
||||||
expect(themeState.current).toBe(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