feat: start implement panels rendering, remove unused files
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { SlideDirection } from "$lib/types/utils";
|
||||
import type { SlideDirection } from "$lib/util-types";
|
||||
import { imageConfigState } from "$states/image.svelte";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
interface Props {
|
||||
@@ -10,8 +12,29 @@
|
||||
let { text, direction }: Props = $props();
|
||||
|
||||
let xDirection = $derived(direction == "next" ? 320 : -320);
|
||||
|
||||
let image: HTMLImageElement | undefined = imageConfigState.image;
|
||||
</script>
|
||||
|
||||
<div in:fly={{ x: xDirection, duration: 300 }} out:fly={{ x: -xDirection, duration: 300 }}>
|
||||
{text}
|
||||
<div class="konva-wrapper" in:fly={{ x: xDirection, duration: 300 }} out:fly={{ x: -xDirection, duration: 300 }}>
|
||||
<Stage width={320} height={100}>
|
||||
<Layer>
|
||||
<Image {image}></Image>
|
||||
{$inspect(image)}
|
||||
<Text {text} x={10} y={10} fontsize="32" fill="white"></Text>
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.konva-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import IconArrowLeft from "$components/ui/Icons/IconArrowLeft.svelte";
|
||||
import IconArrowRight from "$components/ui/Icons/IconArrowRight.svelte";
|
||||
import type { SlideDirection } from "$lib/types/utils";
|
||||
import type { SlideDirection } from "$lib/util-types";
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import IconDownload from "$components/ui/Icons/IconDownload.svelte";
|
||||
import IconEmpty from "$components/ui/Icons/IconEmpty.svelte";
|
||||
import type { SlideDirection } from "$lib/types/utils";
|
||||
import type { SlideDirection } from "$lib/util-types";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import Preview from "./Preview.svelte";
|
||||
import PreviewControls from "./PreviewControls.svelte";
|
||||
@@ -14,7 +14,13 @@
|
||||
|
||||
$effect(() => {
|
||||
$inspect(current, textsState.texts);
|
||||
if (current > textsState.texts.length - 1) current = textsState.texts.length - 1;
|
||||
if (textsState.texts.length === 0) {
|
||||
current = 0;
|
||||
} else {
|
||||
if (current > textsState.texts.length - 1) {
|
||||
current = textsState.texts.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -30,9 +36,9 @@
|
||||
<div class="panel-viewer">
|
||||
<div class="panel-display">
|
||||
{#if textsState.texts.length}
|
||||
<!-- {@const text = } -->
|
||||
{#key current}
|
||||
<Preview text={textsState.texts[current].text} {direction} />
|
||||
{@const text = textsState?.texts[current]?.text}
|
||||
<Preview {text} {direction} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
@@ -64,6 +70,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge" id="panelCount">{text}</span>
|
||||
<span class="badge">{text}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M4.215 7.266h0.003a0.545 0.545 0 0 0 0.539 0.544v0H19.243v0a0.545 0.545 0 0 0 0.542 -0.544h0.002V4.756a0.545 0.545 0 0 0 -0.545 -0.544H4.757a0.545 0.545 0 0 0 -0.545 0.545c0 0.009 0.002 0.018 0.003 0.028z"
|
||||
/>
|
||||
<path
|
||||
d="M19.243 10.201H4.757a0.545 0.545 0 0 0 -0.545 0.545c0 0.009 0.002 0.018 0.003 0.028v2.482h0.003a0.545 0.545 0 0 0 0.539 0.544v0h14.486v0a0.545 0.545 0 0 0 0.542 -0.544h0.002V10.745a0.545 0.545 0 0 0 -0.545 -0.544"
|
||||
/>
|
||||
<path
|
||||
d="M19.243 16.189H4.757a0.545 0.545 0 0 0 -0.545 0.545c0 0.009 0.002 0.018 0.003 0.028v2.482h0.003a0.545 0.545 0 0 0 0.539 0.544v0h14.486v0a0.545 0.545 0 0 0 0.542 -0.545h0.002V16.732a0.545 0.545 0 0 0 -0.545 -0.543"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 747 B |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1">
|
||||
<path
|
||||
d="M8.293 14.293a1 1 0 0 1 1.414 0L12 16.586l2.293-2.293a1 1 0 0 1 1.414 1.414l-3 3a1 1 0 0 1-1.414 0l-3-3a1 1 0 0 1 0-1.414ZM11.293 5.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1-1.414 1.414L12 7.414 9.707 9.707a1 1 0 0 1-1.414-1.414l3-3Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -1,30 +0,0 @@
|
||||
import { building } from "$app/environment";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "path";
|
||||
|
||||
export function loadFonts() {
|
||||
try {
|
||||
const fontsDir = join(process.cwd(), "static", "fonts");
|
||||
const files = readdirSync(fontsDir);
|
||||
|
||||
let fonts = files
|
||||
.filter((file) => /\.(woff2|woff|ttf|otf)$/i.test(file))
|
||||
.map((file) => ({
|
||||
name: file.replace(/\.[^/.]+$/, ""),
|
||||
file,
|
||||
url: `/fonts/${file}`,
|
||||
format: getFormat(file),
|
||||
}));
|
||||
|
||||
return fonts;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getFormat(filename: string) {
|
||||
if (filename.endsWith(".woff2")) return "woff2";
|
||||
if (filename.endsWith(".woff")) return "woff";
|
||||
if (filename.endsWith(".ttf")) return "truetype";
|
||||
return "opentype";
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { building } from "$app/environment";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "path";
|
||||
|
||||
export function loadImages() {
|
||||
try {
|
||||
const imagesDir = join(process.cwd(), "static", "backgrounds");
|
||||
const files = readdirSync(imagesDir);
|
||||
|
||||
let fonts = files.filter((file) => /\.(png|jpg)$/i.test(file));
|
||||
|
||||
return fonts;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public recoverable: boolean = true,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "IMAGE_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "TEXT_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "CANVAS_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "STORAGE_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ERROR_HANDLING } from "../constants";
|
||||
import { AppError } from "../types/errors";
|
||||
|
||||
export function handleError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError) {
|
||||
return error.recoverable ? `Ошибка: ${error.message}. Попробуйте снова.` : `Критическая ошибка: ${error.message}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function isRecoverableError(error: unknown): boolean {
|
||||
if (error instanceof AppError) {
|
||||
return error.recoverable;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, recoverable: boolean = true, details?: unknown): AppError {
|
||||
return new AppError(message, code, recoverable, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = ERROR_HANDLING.MAX_RETRIES,
|
||||
delayMs: number = ERROR_HANDLING.RETRY_DELAY_MS,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempt = 0;
|
||||
|
||||
const attemptOperation = () => {
|
||||
attempt++;
|
||||
operation()
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
if (attempt >= maxRetries) {
|
||||
reject(error);
|
||||
} else {
|
||||
setTimeout(attemptOperation, delayMs * attempt);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
attemptOperation();
|
||||
});
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { IMAGE_SETTINGS } from "../constants";
|
||||
import { ImageError } from "../types/errors";
|
||||
|
||||
export const MAX_FILE_SIZE = IMAGE_SETTINGS.MAX_FILE_SIZE;
|
||||
export const SUPPORTED_FORMATS = IMAGE_SETTINGS.SUPPORTED_FORMATS;
|
||||
|
||||
export type ValidationResult =
|
||||
| {
|
||||
isValid: true;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function validateFileSize(file: File): ValidationResult {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Файл слишком большой. Максимальный размер: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateFileType(file: File): ValidationResult {
|
||||
if (!SUPPORTED_FORMATS.includes(file.type as any)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Неподдерживаемый формат. Допустимые форматы: ${SUPPORTED_FORMATS.join(", ")}`,
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateImageFile(file: File): ValidationResult {
|
||||
const sizeValidation = validateFileSize(file);
|
||||
if (!sizeValidation.isValid) {
|
||||
return sizeValidation;
|
||||
}
|
||||
|
||||
const typeValidation = validateFileType(file);
|
||||
if (!typeValidation.isValid) {
|
||||
return typeValidation;
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateImageUrl(url: string): ValidationResult {
|
||||
try {
|
||||
new URL(url);
|
||||
return { isValid: true };
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Неверный URL формат",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function validateImageElement(img: HTMLImageElement): ValidationResult {
|
||||
if (!img.naturalWidth || !img.naturalHeight) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Изображение не может быть загружено или повреждено",
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export async function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement("img");
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new ImageError("Не удалось загрузить изображение"));
|
||||
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export function getImageDimensions(img: HTMLImageElement): { width: number; height: number } {
|
||||
return {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateAspectRatioFit(
|
||||
srcWidth: number,
|
||||
srcHeight: number,
|
||||
maxWidth: number,
|
||||
maxHeight: number,
|
||||
): { width: number; height: number } {
|
||||
const ratio = Math.min(maxWidth / srcWidth, maxHeight / srcHeight);
|
||||
return {
|
||||
width: srcWidth * ratio,
|
||||
height: srcHeight * ratio,
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { PANEL_SETTINGS } from "../constants";
|
||||
import type { Panel } from "../types/panel";
|
||||
import { handleError, logError } from "./errorHandler";
|
||||
|
||||
const STORAGE_KEY = PANEL_SETTINGS.STORAGE_KEY;
|
||||
const MAX_PANELS = PANEL_SETTINGS.MAX_PANELS_COUNT;
|
||||
|
||||
export interface StorageResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class PanelStorage {
|
||||
savePanel(panel: Panel): StorageResult {
|
||||
try {
|
||||
const panels = this.getAllPanels();
|
||||
|
||||
const existingIndex = panels.findIndex((p) => p.id === panel.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
panels[existingIndex] = panel;
|
||||
} else {
|
||||
panels.unshift(panel);
|
||||
|
||||
if (panels.length > MAX_PANELS) {
|
||||
panels.length = MAX_PANELS;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(panels));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Failed to save panel");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getAllPanels(): Panel[] {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const panels: Panel[] = JSON.parse(data);
|
||||
|
||||
// Convert date strings back to Date objects and validate
|
||||
return panels
|
||||
.map((panel) => ({
|
||||
...panel,
|
||||
createdAt: new Date(panel.createdAt),
|
||||
updatedAt: new Date(panel.updatedAt),
|
||||
}))
|
||||
.filter((panel) => this.validatePanel(panel));
|
||||
} catch (error) {
|
||||
logError(error, "Failed to load panels");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getPanelById(id: string): Panel | undefined {
|
||||
try {
|
||||
const panels = this.getAllPanels();
|
||||
return panels.find((p) => p.id === id) || undefined;
|
||||
} catch (error) {
|
||||
logError(error, "Failed to get panel by id");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
deletePanel(id: string): StorageResult {
|
||||
try {
|
||||
const panels = this.getAllPanels();
|
||||
const filteredPanels = panels.filter((p) => p.id !== id);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filteredPanels));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Failed to delete panel");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка удаления панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): StorageResult {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Failed to clear panels");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка очистки хранилища"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private validatePanel(panel: any): panel is Panel {
|
||||
return (
|
||||
typeof panel === "object" &&
|
||||
typeof panel.id === "string" &&
|
||||
typeof panel.backgroundImage === "string" &&
|
||||
typeof panel.text === "object" &&
|
||||
typeof panel.text?.id === "string" &&
|
||||
typeof panel.text?.text === "string" &&
|
||||
typeof panel.height === "number" &&
|
||||
panel.height > 0 &&
|
||||
panel.height <= PANEL_SETTINGS.PANEL_HEIGHT_MAX &&
|
||||
panel.createdAt instanceof Date &&
|
||||
panel.updatedAt instanceof Date
|
||||
);
|
||||
}
|
||||
|
||||
getPanelCount(): number {
|
||||
return this.getAllPanels().length;
|
||||
}
|
||||
|
||||
hasSpaceForNewPanel(): boolean {
|
||||
return this.getPanelCount() < MAX_PANELS;
|
||||
}
|
||||
}
|
||||
|
||||
export const panelStorage = new PanelStorage();
|
||||
@@ -1,98 +0,0 @@
|
||||
import pkg from "file-saver";
|
||||
import type { Stage } from "konva/lib/Stage";
|
||||
import { ImageError } from "../lib/types/errors";
|
||||
import type { Panel } from "../lib/types/panel";
|
||||
import { handleError, logError } from "../lib/utils/errorHandler";
|
||||
const { saveAs } = pkg;
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ExportService {
|
||||
async exportPanel(panel: Panel, konvaStage: Stage, filename?: string): Promise<ExportResult> {
|
||||
try {
|
||||
if (!konvaStage) {
|
||||
throw new ImageError("Konva Stage не передан для экспорта");
|
||||
}
|
||||
|
||||
const blob = await this.exportKonvaStageToBlob(konvaStage);
|
||||
|
||||
if (!blob) {
|
||||
throw new ImageError("Не удалось создать изображение");
|
||||
}
|
||||
|
||||
const defaultFilename = `twitch-panel-${panel.id}.png`;
|
||||
saveAs(blob, filename || defaultFilename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Panel export failed");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка экспорта панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async exportKonvaStageToBlob(konvaStage: Stage): Promise<Blob | null> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспортирует несколько панелей в ZIP архив
|
||||
*/
|
||||
async exportPanels(panels: Panel[]): Promise<ExportResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Пакетный экспорт еще не реализован",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик для скачивания одной панели
|
||||
*/
|
||||
async handleDownload(panel: Panel, konvaStage: Stage): Promise<void> {
|
||||
const result = await this.exportPanel(panel, konvaStage);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Ошибка экспорта панели");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик для скачивания всех панелей
|
||||
*/
|
||||
async handleDownloadAll(panels: Panel[]): Promise<void> {
|
||||
const result = await this.exportPanels(panels);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Ошибка экспорта панелей");
|
||||
}
|
||||
}
|
||||
|
||||
getErrorMessage(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
setErrorMessage(message: string): void {
|
||||
console.error("ExportService error:", message);
|
||||
}
|
||||
}
|
||||
|
||||
export const exportService = new ExportService();
|
||||
@@ -1,130 +0,0 @@
|
||||
import { PANEL_SETTINGS } from "../lib/constants";
|
||||
import { setCurrentStep } from "../stores/uiStore";
|
||||
|
||||
export class ImageService {
|
||||
private static instance: ImageService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ImageService {
|
||||
if (!ImageService.instance) {
|
||||
ImageService.instance = new ImageService();
|
||||
}
|
||||
return ImageService.instance;
|
||||
}
|
||||
|
||||
async loadDefaultBackground(): Promise<string> {
|
||||
const defaultBackground = PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE;
|
||||
const response = await fetch(defaultBackground);
|
||||
if (!response.ok) throw new Error("Не удалось загрузить фоновое изображение");
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
handleImageUpload(image: string): void {
|
||||
setCurrentStep("crop");
|
||||
}
|
||||
|
||||
handleUploadNewImage(): void {
|
||||
setCurrentStep("upload");
|
||||
}
|
||||
|
||||
handleCropComplete(croppedImage: string): void {
|
||||
setCurrentStep("text");
|
||||
}
|
||||
|
||||
handleCropCancel(): void {
|
||||
setCurrentStep("text");
|
||||
}
|
||||
|
||||
async handleFileUpload(file: File): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
success: true,
|
||||
image: reader.result as string,
|
||||
});
|
||||
reader.onerror = () =>
|
||||
reject({
|
||||
success: false,
|
||||
error: "Ошибка чтения файла",
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async handlePasteUpload(pasteEvent: ClipboardEvent): Promise<any> {
|
||||
const items = pasteEvent.clipboardData?.items;
|
||||
if (!items || items.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "В буфере обмена не найдено изображений",
|
||||
};
|
||||
}
|
||||
|
||||
let imageItem: DataTransferItem | undefined = undefined;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image") !== -1) {
|
||||
imageItem = items[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageItem) {
|
||||
return {
|
||||
success: false,
|
||||
error: "В буфере обмена не найдено изображений",
|
||||
};
|
||||
}
|
||||
|
||||
const file = imageItem.getAsFile();
|
||||
if (!file) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Не удалось получить файл из буфера обмена",
|
||||
};
|
||||
}
|
||||
|
||||
return this.handleFileUpload(file);
|
||||
}
|
||||
|
||||
async handleUrlUpload(url: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Не удалось загрузить изображение по URL",
|
||||
};
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
success: true,
|
||||
image: reader.result as string,
|
||||
});
|
||||
reader.onerror = () =>
|
||||
reject({
|
||||
success: false,
|
||||
error: "Ошибка чтения изображения",
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Ошибка загрузки изображения по URL",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageService = ImageService.getInstance();
|
||||
@@ -1,71 +0,0 @@
|
||||
import { PANEL_SETTINGS } from "../lib/constants";
|
||||
import type { Panel, TextItem } from "../lib/types/panel";
|
||||
import { createPanelFromText, updatePanelText } from "../stores/panelStore";
|
||||
|
||||
export class PanelService {
|
||||
private static instance: PanelService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): PanelService {
|
||||
if (!PanelService.instance) {
|
||||
PanelService.instance = new PanelService();
|
||||
}
|
||||
return PanelService.instance;
|
||||
}
|
||||
|
||||
validateText(text: string): boolean {
|
||||
return text.trim().length > 0;
|
||||
}
|
||||
|
||||
isDuplicateText(texts: Array<{ id: string; text: string }>, text: string, excludeId?: string): boolean {
|
||||
return texts.some((t) => t.id !== excludeId && t.text === text);
|
||||
}
|
||||
|
||||
addText(texts: Array<{ id: string; text: string }>, text: string): Array<{ id: string; text: string }> {
|
||||
if (!this.validateText(text)) return texts;
|
||||
if (this.isDuplicateText(texts, text)) return texts;
|
||||
|
||||
return [...texts, { id: crypto.randomUUID(), text }];
|
||||
}
|
||||
|
||||
updateText(
|
||||
texts: Array<{ id: string; text: string }>,
|
||||
id: string,
|
||||
newText: string,
|
||||
): Array<{ id: string; text: string }> {
|
||||
if (!this.validateText(newText)) return texts;
|
||||
if (this.isDuplicateText(texts, newText, id)) return texts;
|
||||
|
||||
return texts.map((t) => (t.id === id ? { ...t, text: newText } : t));
|
||||
}
|
||||
|
||||
deleteText(texts: Array<{ id: string; text: string }>, id: string): Array<{ id: string; text: string }> {
|
||||
return texts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
updatePanelsFromTexts(
|
||||
texts: Array<{ id: string; text: string }>,
|
||||
panels: Panel[],
|
||||
backgroundImage: string,
|
||||
textSettings?: Partial<TextItem>,
|
||||
): Panel[] {
|
||||
return texts.map((textItem) => {
|
||||
const existingPanel = panels.find((p) => p.text.text === textItem.text);
|
||||
if (existingPanel) {
|
||||
return updatePanelText(existingPanel, textItem.text);
|
||||
}
|
||||
return createPanelFromText(backgroundImage, textItem.text, PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT, textSettings);
|
||||
});
|
||||
}
|
||||
|
||||
updatePanelsBackground(panels: Panel[], newBackground: string): Panel[] {
|
||||
return panels.map((panel) => ({
|
||||
...panel,
|
||||
backgroundImage: newBackground,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const panelService = PanelService.getInstance();
|
||||
@@ -0,0 +1,120 @@
|
||||
export type ImageConfig = {
|
||||
image: HTMLImageElement | undefined;
|
||||
imageLink: string;
|
||||
imageReady: boolean;
|
||||
cropLeft: number;
|
||||
cropTop: number;
|
||||
cropRight: number;
|
||||
cropBottom: number;
|
||||
};
|
||||
|
||||
async function createState() {
|
||||
const defaults: ImageConfig = {
|
||||
image: undefined,
|
||||
imageLink: "",
|
||||
imageReady: false,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
};
|
||||
|
||||
let state: ImageConfig = $state({ ...defaults });
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
function cleanup() {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
currentAbortController = null;
|
||||
}
|
||||
|
||||
if (state.image) {
|
||||
state.image.onload = null;
|
||||
state.image.onerror = null;
|
||||
state.image.src = "";
|
||||
state.image = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImageByLink(link: string): Promise<void> {
|
||||
cleanup();
|
||||
|
||||
state.imageReady = false;
|
||||
state.imageLink = link;
|
||||
|
||||
currentAbortController = new AbortController();
|
||||
const { signal } = currentAbortController;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
|
||||
state.image = img;
|
||||
state.imageReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
img.src = "";
|
||||
|
||||
state.imageReady = false;
|
||||
reject(new Error(`Failed to load image: ${link}`));
|
||||
};
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
img.src = "";
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
img.src = link;
|
||||
});
|
||||
}
|
||||
|
||||
await uploadImageByLink("./backgrounds/b1.jpg");
|
||||
|
||||
return {
|
||||
get image() {
|
||||
return state.image;
|
||||
},
|
||||
get imageReady() {
|
||||
return state.imageReady;
|
||||
},
|
||||
get imageLink() {
|
||||
return state.imageLink;
|
||||
},
|
||||
|
||||
uploadImageByLink,
|
||||
|
||||
reset() {
|
||||
cleanup();
|
||||
Object.assign(state, defaults);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const imageConfigState = await createState();
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HexColor, TextAlign } from "$lib/types/text";
|
||||
import type { HexColor, TextAlign } from "$lib/util-types";
|
||||
|
||||
export type TextConfig = {
|
||||
fontSize: number;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PANEL_SETTINGS, TYPOGRAPHY } from "../lib/constants";
|
||||
import { type Panel, type TextItem } from "../lib/types/panel";
|
||||
|
||||
export const panelStore: Writable<Panel | undefined> = writable(undefined);
|
||||
|
||||
export const textSettingsStore = writable<Partial<TextItem>>({
|
||||
fontSize: TYPOGRAPHY.FONT_SIZE_DEFAULT,
|
||||
fontFamily: TYPOGRAPHY.FONT_FAMILY_DEFAULT,
|
||||
color: TYPOGRAPHY.TEXT_COLOR_DEFAULT,
|
||||
textAlign: TYPOGRAPHY.TEXT_ALIGN_LEFT,
|
||||
paddingX: TYPOGRAPHY.PADDING_X_DEFAULT,
|
||||
verticalOffset: 0,
|
||||
});
|
||||
|
||||
export const updateAllTextSettings = (settings: Partial<TextItem>) => {
|
||||
textSettingsStore.update((current) => ({ ...current, ...settings }));
|
||||
};
|
||||
|
||||
export const createEmptyPanel = (height: number = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT): Panel => {
|
||||
const defaultText: TextItem = {
|
||||
id: uuidv4(),
|
||||
text: "",
|
||||
fontSize: TYPOGRAPHY.FONT_SIZE_DEFAULT,
|
||||
fontFamily: TYPOGRAPHY.FONT_FAMILY_DEFAULT,
|
||||
color: TYPOGRAPHY.TEXT_COLOR_DEFAULT,
|
||||
textAlign: TYPOGRAPHY.TEXT_ALIGN_CENTER,
|
||||
paddingX: TYPOGRAPHY.PADDING_X_DEFAULT,
|
||||
verticalOffset: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
backgroundImage: "",
|
||||
text: defaultText,
|
||||
height,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
export const updatePanel = (panel: Panel, updates: Partial<Panel>): Panel => {
|
||||
return {
|
||||
...panel,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
export const updatePanelText = (panel: Panel, text: string): Panel => {
|
||||
return updatePanel(panel, {
|
||||
text: { ...panel.text, text },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateTextProperties = (panel: Panel, updates: Partial<TextItem>): Panel => {
|
||||
return updatePanel(panel, {
|
||||
text: { ...panel.text, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
export const createPanelFromText = (
|
||||
backgroundImage: string,
|
||||
text: string,
|
||||
height: number = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT,
|
||||
textSettings?: Partial<TextItem>,
|
||||
): Panel => {
|
||||
const newText: TextItem = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
fontSize: textSettings?.fontSize ?? TYPOGRAPHY.FONT_SIZE_DEFAULT,
|
||||
fontFamily: textSettings?.fontFamily ?? TYPOGRAPHY.FONT_FAMILY_DEFAULT,
|
||||
color: textSettings?.color ?? TYPOGRAPHY.TEXT_COLOR_DEFAULT,
|
||||
textAlign: textSettings?.textAlign ?? TYPOGRAPHY.TEXT_ALIGN_CENTER,
|
||||
paddingX: textSettings?.paddingX ?? TYPOGRAPHY.PADDING_X_DEFAULT,
|
||||
verticalOffset: textSettings?.verticalOffset ?? 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
backgroundImage,
|
||||
text: newText,
|
||||
height,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { type UIState } from "../lib/types/panel";
|
||||
|
||||
export const uiStore: Writable<UIState> = writable({
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
currentStep: "text",
|
||||
showCropModal: false,
|
||||
showTextManager: false,
|
||||
});
|
||||
|
||||
export const setLoading = (loading: boolean): void => {
|
||||
uiStore.update((state) => ({ ...state, isLoading: loading }));
|
||||
};
|
||||
|
||||
export const setError = (error: string | undefined): void => {
|
||||
uiStore.update((state) => ({ ...state, error }));
|
||||
};
|
||||
|
||||
export const clearError = (): void => {
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
export const setCurrentStep = (step: UIState["currentStep"]): void => {
|
||||
uiStore.update((state) => ({ ...state, currentStep: step }));
|
||||
};
|
||||
|
||||
export const showCropModal = (show: boolean): void => {
|
||||
uiStore.update((state) => ({ ...state, showCropModal: show }));
|
||||
};
|
||||
|
||||
export const showTextManager = (show: boolean): void => {
|
||||
uiStore.update((state) => ({ ...state, showTextManager: show }));
|
||||
};
|
||||
|
||||
export const resetUI = (): void => {
|
||||
uiStore.set({
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
currentStep: "text",
|
||||
showCropModal: false,
|
||||
showTextManager: false,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user