feat: implement save all

This commit is contained in:
2026-02-07 18:35:29 +05:00
parent 0444b0cd2c
commit 2eab18569e
9 changed files with 258 additions and 50 deletions
+24 -45
View File
@@ -1,60 +1,39 @@
<script lang="ts">
import { PANEL_SETTINGS, type SlideDirectionType } from "$lib/constants";
import { PANEL_SETTINGS } from "$lib/constants";
import { imageConfigState } from "$states/imageConfig.svelte";
import { textConfigState } from "$states/textConfig.svelte";
import { Image, Layer, Stage, Text } from "svelte-konva";
import { fly } from "svelte/transition";
interface Props {
text: string;
direction: SlideDirectionType;
stage: Stage | undefined;
}
let { text, direction }: Props = $props();
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
let { text, stage = $bindable() }: Props = $props();
let image: HTMLImageElement | undefined = imageConfigState.image;
let { align, fontSize, fontFamily, paddingX, offsetY, color } = textConfigState;
let x = $derived(paddingX);
let y = $derived(10 + offsetY);
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * paddingX);
let x = $derived(textConfigState.paddingX);
let y = $derived(10 + textConfigState.offsetY);
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
</script>
<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(paddingX)}
<Text
{text}
{x}
{y}
{width}
{height}
fontSize={textConfigState.fontSize}
fill={textConfigState.color}
fontFamily={textConfigState.fontFamily}
align={textConfigState.align}
wrap="none"
ellipsis={true}
/>
</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>
<Stage width={320} height={100} bind:this={stage}>
<Layer>
<Image {image}></Image>
<Text
{text}
{x}
{y}
{width}
{height}
fontSize={textConfigState.fontSize}
fill={textConfigState.color}
fontFamily={textConfigState.fontFamily}
align={textConfigState.align}
wrap="none"
ellipsis={true}
/>
</Layer>
</Stage>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { textsState } from "$states/texts.svelte";
import Preview from "./Preview.svelte";
</script>
<div class="outside-display">
{#each textsState.texts as { text, id }, idx (id)}
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
{:else}
<p>No texts to preview</p>
{/each}
</div>
<style>
.outside-display {
position: fixed;
top: -1000px;
left: -1000px;
}
</style>
@@ -3,6 +3,7 @@
import IconArrowLeft from "$components/ui/Icons/IconArrowLeft.svelte";
import IconArrowRight from "$components/ui/Icons/IconArrowRight.svelte";
import type { SlideDirectionType } from "$lib/constants";
import { PANEL_SETTINGS } from "$lib/constants";
interface Props {
current: number;
@@ -24,6 +25,8 @@
if (current < max - 1) current++;
direction = "next";
}
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
</script>
<Button icon={IconArrowLeft} type="mini" onclick={prev} disabled={isFirst} />
+45 -5
View File
@@ -4,16 +4,22 @@
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 { SlideDirectionType } from "$lib/constants";
import { PANEL_SETTINGS, type SlideDirectionType } from "$lib/constants";
import { downloadService, type DownloadItem } from "$services/downloadService";
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { konvaStageState } from "$states/konvaStage.svelte";
import { textsState } from "$states/texts.svelte";
import { fly } from "svelte/transition";
import Preview from "./Preview.svelte";
import PreviewAll from "./PreviewAll.svelte";
import PreviewControls from "./PreviewControls.svelte";
let current: number = $state(0);
let direction: SlideDirectionType = $state("next");
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
$effect(() => {
$inspect(current, textsState.texts);
if (textsState.texts.length === 0) {
current = 0;
} else {
@@ -22,6 +28,22 @@
}
}
});
function downloadAll() {
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
filename: text.text,
stage: konvaAllStagesState[idx]?.node,
}));
downloadService.downloadAll(downloadItems);
}
function downloadCurrent() {
let node = konvaStageState.stage?.node;
if (node) {
downloadService.downloadPanel(node, textsState.texts[current].text);
}
}
</script>
{#snippet panelTitle()}
@@ -38,7 +60,13 @@
{#if textsState.texts.length}
{#key current}
{@const text = textsState?.texts[current]?.text}
<Preview {text} {direction} />
<div
class="konva-wrapper"
in:fly={{ x: xDirection, duration: 300 }}
out:fly={{ x: -xDirection, duration: 300 }}
>
<Preview {text} bind:stage={konvaStageState.stage} />
</div>
{/key}
{:else}
<div class="empty-state">
@@ -49,11 +77,12 @@
</div>
<div class="panel-actions">
<Button label="Скачать всё" icon={IconDownload} />
<Button label="Скачать" type="outline" icon={IconDownload} />
<Button label="Скачать всё" icon={IconDownload} onclick={downloadAll} />
<Button label="Скачать" type="outline" icon={IconDownload} onclick={downloadCurrent} />
</div>
</div>
</Card>
<PreviewAll />
<style>
.panel-viewer {
@@ -94,4 +123,15 @@
.empty-state p {
font-size: 14px;
}
.konva-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
export class AppError extends Error {
constructor(
message: string,
public code: string,
public details?: unknown,
) {
super(message);
this.name = "AppError";
}
}
export class ImageError extends AppError {
constructor(message: string) {
super(message, "IMAGE_ERROR");
}
}
export class TextError extends AppError {
constructor(message: string) {
super(message, "TEXT_ERROR");
}
}
export class CanvasError extends AppError {
constructor(message: string) {
super(message, "CANVAS_ERROR");
}
}
export class StorageError extends AppError {
constructor(message: string) {
super(message, "STORAGE_ERROR");
}
}
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
+26
View File
@@ -0,0 +1,26 @@
import { AppError } from "$lib/error.types";
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
if (error instanceof AppError || error instanceof Error) {
return `${defaultMessage}: ${error.message}`;
}
if (typeof error === "string") {
return `${defaultMessage}: ${error}`;
}
return `${defaultMessage}: Произошла неизвестная ошибка`;
}
export function createError(message: string, code: string, recoverable: boolean = true, details?: unknown): AppError {
return new AppError(message, code, recoverable);
}
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,
});
}
+83
View File
@@ -0,0 +1,83 @@
import { ImageError } from "$lib/error.types";
import { formatError, logError } from "$lib/utils/errorUtils";
import { saveAs } from "file-saver";
import JSZip from "jszip";
import { Stage } from "konva/lib/Stage";
export type DownloadResult =
| {
success: true;
}
| {
success: false;
error: string;
};
export interface DownloadItem {
filename: string;
stage: Stage;
}
export class DownloadService {
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
try {
const blob = await this.stageToBlob(konvaStage);
const filename = `${label}.png`;
saveAs(blob, filename);
return {
success: true,
};
} catch (error) {
logError(error, "Ошибка сохранения панели");
return {
success: false,
error: formatError(error, "Ошибка сохранения панели"),
};
}
}
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
try {
const zip = new JSZip();
for (let panel of panels) {
const blob = await this.stageToBlob(panel.stage);
zip.file(`${panel.filename}.png`, blob);
}
const zipBlob = await zip.generateAsync({ type: "blob" });
saveAs(zipBlob, "panels.zip");
return {
success: true,
};
} catch (error) {
logError(error, "Ошибка сохранения архива");
return {
success: false,
error: formatError(error, "Ошибка сохранения архива"),
};
}
}
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
}
return new Promise((resolve, reject) => {
konvaStage.toBlob({
callback: (blob: Blob | null) => {
if (blob) {
resolve(blob);
} else {
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
}
},
});
});
}
}
export const downloadService = new DownloadService();
+3
View File
@@ -0,0 +1,3 @@
import type { Stage } from "svelte-konva";
export const konvaAllStagesState: Array<Stage> = $state([]);
+16
View File
@@ -0,0 +1,16 @@
import type { Stage } from "svelte-konva";
function createState() {
let stage: Stage | undefined = $state(undefined);
return {
get stage(): Stage | undefined {
return stage;
},
set stage(newStage: Stage) {
stage = newStage;
},
};
}
export const konvaStageState = createState();