feat: add image filters, update some logic and workflow

This commit is contained in:
2026-02-21 11:41:30 +05:00
parent 53662293b8
commit 42f250bcb5
16 changed files with 384 additions and 123 deletions
+2 -3
View File
@@ -48,10 +48,9 @@
--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); */
--border-main: oklch(from var(--brand-main) 0.22 0.04 h); --border-main: oklch(from var(--brand-main) 0.26 0.04 h);
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h); --border-strong: oklch(from var(--brand-main) 0.4 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);
+46 -8
View File
@@ -1,12 +1,56 @@
<script lang="ts"> <script lang="ts">
import { IMAGE_SETTINGS, PANEL_SETTINGS } from "$lib/constants";
import { imageState } from "$states/image.svelte"; import { imageState } from "$states/image.svelte";
import Konva from "konva";
import { onMount } from "svelte";
import { Image, Layer, Stage } from "svelte-konva";
let active = $state(false); let active = $state(false);
let masterImage: Image | undefined = $state();
let contrast = $derived(imageState.appliedFilters.contrast);
let brightness = $derived(imageState.appliedFilters.brightness);
let hue = $derived(imageState.appliedFilters.hue);
let saturation = $derived(imageState.appliedFilters.saturation);
let luminance = $derived(imageState.appliedFilters.luminance);
function updateMasterImage() {
if (masterImage?.node && masterImage?.node?.width() > 0) {
masterImage?.node.cache();
imageState.masterImage = masterImage?.node.toCanvas();
}
}
$effect(() => {
void contrast;
void brightness;
void hue;
void saturation;
void luminance;
updateMasterImage();
});
onMount(async () => {
updateMasterImage();
});
</script> </script>
<div class="crop-canvas-container"> <div class="crop-canvas-container">
<img src={imageState.fullImage} alt="Background for use" /> <Stage width={PANEL_SETTINGS.PANEL_WIDTH} height={PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT}>
<canvas class="crop-canvas"></canvas> <Layer>
<Image
image={imageState.croppedImage}
bind:this={masterImage}
filters={[Konva.Filters.Brightness, Konva.Filters.Contrast, Konva.Filters.HSL]}
brightness={brightness / IMAGE_SETTINGS.BRIGHTNESS_DIVIDER}
{contrast}
{hue}
saturation={saturation / IMAGE_SETTINGS.SATURATION_DIVIDER}
luminance={luminance / IMAGE_SETTINGS.LUMINANCE_DIVIDER}
/>
</Layer>
</Stage>
<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>
@@ -30,12 +74,6 @@
cursor: crosshair; cursor: crosshair;
} }
.crop-canvas {
width: 100%;
height: 100%;
display: block;
}
.crop-box { .crop-box {
position: absolute; position: absolute;
border: 2px solid var(--action-primary); border: 2px solid var(--action-primary);
+30 -13
View File
@@ -9,14 +9,10 @@
import { imageState } from "$states/image.svelte"; import { imageState } from "$states/image.svelte";
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 Sliders from "~icons/lucide/sliders-horizontal";
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(IMAGE_SETTINGS.DEFAULT_BRIGHTNESS);
let contrast = $state(IMAGE_SETTINGS.DEFAULT_CONTRAST);
let hue = $state(IMAGE_SETTINGS.DEFAULT_HUE);
let chroma = $state(IMAGE_SETTINGS.DEFAULT_CHROMA);
async function handlePaste(event: ClipboardEvent) { async function handlePaste(event: ClipboardEvent) {
let image = await uploadService.fromClipboard(event); let image = await uploadService.fromClipboard(event);
if (image.ok) { if (image.ok) {
@@ -35,29 +31,50 @@
<Button label="Сбросить" ariaLabel="Reset" icon={Reset} type="outline" /> <Button label="Сбросить" ariaLabel="Reset" icon={Reset} type="outline" />
</div> </div>
<SettingsGrid> <SettingsGrid label="Настройки изображения" icon={Sliders}>
<SettingsRow label="Смещение цвета"> <SettingsRow label="Сдвиг цвета">
<RangeSlider bind:value={hue} min={IMAGE_SETTINGS.HUE_MIN} max={IMAGE_SETTINGS.HUE_MAX} /> <RangeSlider
bind:value={imageState.hue}
min={IMAGE_SETTINGS.HUE_MIN}
max={IMAGE_SETTINGS.HUE_MAX}
defaultValue={IMAGE_SETTINGS.HUE_DEFAULT}
showReset={true}
/>
</SettingsRow> </SettingsRow>
<SettingsRow label="Насыщенность"> <SettingsRow label="Насыщенность">
<RangeSlider <RangeSlider
bind:value={chroma} bind:value={imageState.saturation}
min={IMAGE_SETTINGS.CHROMA_MIN} min={IMAGE_SETTINGS.SATURATION_MIN}
max={IMAGE_SETTINGS.CHROMA_MAX} max={IMAGE_SETTINGS.SATURATION_MAX}
defaultValue={IMAGE_SETTINGS.SATURATION_DEFAULT}
showReset={true}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Яркость"> <SettingsRow label="Яркость">
<RangeSlider <RangeSlider
bind:value={brightness} bind:value={imageState.luminance}
min={IMAGE_SETTINGS.LUMINANCE_MIN}
max={IMAGE_SETTINGS.LUMINANCE_MAX}
defaultValue={IMAGE_SETTINGS.LUMINANCE_DEFAULT}
showReset={true}
/>
</SettingsRow>
<SettingsRow label="Светлота">
<RangeSlider
bind:value={imageState.brightness}
min={IMAGE_SETTINGS.BRIGHTNESS_MIN} min={IMAGE_SETTINGS.BRIGHTNESS_MIN}
max={IMAGE_SETTINGS.BRIGHTNESS_MAX} max={IMAGE_SETTINGS.BRIGHTNESS_MAX}
defaultValue={IMAGE_SETTINGS.BRIGHTNESS_DEFAULT}
showReset={true}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Контраст"> <SettingsRow label="Контраст">
<RangeSlider <RangeSlider
bind:value={contrast} bind:value={imageState.contrast}
min={IMAGE_SETTINGS.CONTRAST_MIN} min={IMAGE_SETTINGS.CONTRAST_MIN}
max={IMAGE_SETTINGS.CONTRAST_MAX} max={IMAGE_SETTINGS.CONTRAST_MAX}
defaultValue={IMAGE_SETTINGS.CONTRAST_DEFAULT}
showReset={true}
/> />
</SettingsRow> </SettingsRow>
</SettingsGrid> </SettingsGrid>
+28 -2
View File
@@ -1,15 +1,26 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import Button from "$components/ui/Button.svelte";
import type { Component, Snippet } from "svelte";
interface Props { interface Props {
children: Snippet; children: Snippet;
label: string;
icon: Component;
} }
let { children }: Props = $props(); let { children, label = "fuf", icon }: Props = $props();
let expanded = $state(false);
</script> </script>
<div class="settings-grid"> <div class="settings-grid">
<label>
{label}
<Button {icon} ariaLabel="Expand" type="mini" onclick={() => (expanded = !expanded)} />
</label>
{#if expanded}
{@render children()} {@render children()}
{/if}
</div> </div>
<style> <style>
@@ -17,5 +28,20 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 12px;
background: var(--surface-subtle);
border-radius: var(--radius);
border: 1px solid var(--border-main);
}
label {
color: var(--text-main);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 16px;
cursor: pointer;
user-select: none;
} }
</style> </style>
+1 -5
View File
@@ -14,7 +14,6 @@
let x = $derived(textConfigState.paddingX); let x = $derived(textConfigState.paddingX);
let y = $derived(textConfigState.offsetY); let y = $derived(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;
</script> </script>
<Stage <Stage
@@ -23,20 +22,17 @@
bind:this={stage} bind:this={stage}
> >
<Layer> <Layer>
<Image image={imageState.croppedImage}></Image> <Image image={imageState.masterImage} listening={false} />
<Text <Text
{text} {text}
{x} {x}
{y} {y}
{width} {width}
{height}
fontSize={textConfigState.fontSize} fontSize={textConfigState.fontSize}
stroke={textConfigState.outlined ? textConfigState.color : "transparent"} stroke={textConfigState.outlined ? textConfigState.color : "transparent"}
fill={textConfigState.outlined ? "transparent" : textConfigState.color} fill={textConfigState.outlined ? "transparent" : textConfigState.color}
fontFamily={textConfigState.fontFamily} fontFamily={textConfigState.fontFamily}
align={textConfigState.align} align={textConfigState.align}
wrap="none"
ellipsis={true}
/> />
</Layer> </Layer>
</Stage> </Stage>
@@ -4,11 +4,13 @@
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 { imageState } from "$states/image.svelte";
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 Loader from "~icons/lucide/loader";
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";
@@ -67,6 +69,9 @@
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }} in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }} out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
> >
<div class="loader" class:loading={imageState.isReady === false}>
<Loader />
</div>
<Preview {text} bind:stage={konvaStageState.stage} /> <Preview {text} bind:stage={konvaStageState.stage} />
</div> </div>
{/key} {/key}
@@ -141,4 +146,29 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.loader {
position: absolute;
top: 0;
left: 0;
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
transition: 0.3s;
opacity: 0;
&.loading {
opacity: 1;
animation: spin 3s linear infinite;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style> </style>
+2 -1
View File
@@ -9,10 +9,11 @@
import SelectFont from "$components/ui/SelectFont.svelte"; import SelectFont from "$components/ui/SelectFont.svelte";
import { TYPOGRAPHY } from "$lib/constants"; import { TYPOGRAPHY } from "$lib/constants";
import { textConfigState } from "$states/textConfig.svelte"; import { textConfigState } from "$states/textConfig.svelte";
import TextSettings from "~icons/lucide/text-initial";
</script> </script>
<Card title="Настройки текста"> <Card title="Настройки текста">
<SettingsGrid> <SettingsGrid label="Настройки шрифта" icon={TextSettings}>
<SettingsRow label="Размер"> <SettingsRow label="Размер">
<RangeSlider <RangeSlider
bind:value={textConfigState.fontSize} bind:value={textConfigState.fontSize}
+14 -14
View File
@@ -27,7 +27,7 @@
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" || type === "mini"}
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"}
@@ -79,6 +79,19 @@
--btn-hover: var(--action-secondary-hover); --btn-hover: var(--action-secondary-hover);
} }
.btn-mini {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border-main);
border-radius: var(--radius);
justify-content: center;
}
.btn-mini:hover:not(:disabled) {
border-color: var(--border-main);
}
.btn-outline { .btn-outline {
background: transparent; background: transparent;
color: var(--text-main); color: var(--text-main);
@@ -95,19 +108,6 @@
--btn-main: var(--danger-base); --btn-main: var(--danger-base);
} }
.btn-mini {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border-main);
border-radius: var(--radius);
justify-content: center;
}
.btn-mini:hover:not(:disabled) {
border-color: var(--border-main);
}
.grow { .grow {
flex-grow: 1; flex-grow: 1;
justify-content: center; justify-content: center;
+16 -1
View File
@@ -1,19 +1,34 @@
<script lang="ts"> <script lang="ts">
import type { ChangeEventHandler } from "svelte/elements"; import type { ChangeEventHandler } from "svelte/elements";
import Reset from "~icons/lucide/rotate-ccw";
import Button from "./Button.svelte";
interface Props { interface Props {
value: number; value: number;
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
defaultValue?: number;
showReset?: boolean;
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 = () => {},
defaultValue = 0,
showReset = false,
}: 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>
{#if showReset}
<Button ariaLabel="Reset" icon={Reset} type="mini" onclick={() => (value = defaultValue)} />
{/if}
<style> <style>
.slider { .slider {
+16 -9
View File
@@ -51,21 +51,28 @@ export const IMAGE_SETTINGS = {
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg", DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
DEFAULT_HUE: 0, HUE_DEFAULT: 0,
HUE_MIN: 0, HUE_MIN: 0,
HUE_MAX: 360, HUE_MAX: 360,
DEFAULT_CHROMA: 100, SATURATION_DEFAULT: 0,
CHROMA_MIN: 0, SATURATION_MIN: -20,
CHROMA_MAX: 300, SATURATION_MAX: 50,
SATURATION_DIVIDER: 10,
DEFAULT_BRIGHTNESS: 100, LUMINANCE_DEFAULT: 0,
LUMINANCE_MIN: -100,
LUMINANCE_MAX: 100,
LUMINANCE_DIVIDER: 100,
BRIGHTNESS_DEFAULT: 100,
BRIGHTNESS_MIN: 0, BRIGHTNESS_MIN: 0,
BRIGHTNESS_MAX: 100, BRIGHTNESS_MAX: 200,
BRIGHTNESS_DIVIDER: 100,
DEFAULT_CONTRAST: 100, CONTRAST_DEFAULT: 0,
CONTRAST_MIN: 50, CONTRAST_MIN: -100,
CONTRAST_MAX: 150, CONTRAST_MAX: 100,
} as const; } as const;
export const SlideDirection = { export const SlideDirection = {
+126 -28
View File
@@ -1,3 +1,10 @@
import { IMAGE_SETTINGS } from "$lib/constants";
import { withPersistence, type Persistable } from "./persisted.svelte";
// export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 200;
export const DEBOUNCE_DURATION = 150;
export const INITIAL_DELAY_DURATION = 500;
export interface Rect { export interface Rect {
left: number; left: number;
top: number; top: number;
@@ -5,46 +12,137 @@ export interface Rect {
bottom: number; bottom: number;
} }
export type ImageConfig = { export interface ImageConfigDTO {
fullImage: string | null;
cropRect: Rect;
brightness: number;
contrast: number;
hue: number;
saturation: number;
luminance: number;
}
const ImageConfigDTOKeys: (keyof ImageConfigDTO)[] = [
"fullImage",
"cropRect",
"brightness",
"contrast",
"hue",
"saturation",
"luminance",
];
export interface ImageConfig {
fullImage: string | null; fullImage: string | null;
croppedImage: HTMLImageElement | undefined; croppedImage: HTMLImageElement | undefined;
cropRect: Rect; cropRect: Rect;
}; brightness: number;
contrast: number;
hue: number;
saturation: number;
luminance: number;
isReady: boolean;
appliedFilters: FilterConfig;
}
function createState(): ImageConfig & { reset: () => void } { interface FilterConfig {
let fullImage = $state<string | null>(null); brightness: number;
let croppedImage = $state<HTMLImageElement | undefined>(undefined); contrast: number;
let crop: Rect = $state({ left: 0, top: 0, right: 0, bottom: 0 }); hue: number;
saturation: number;
luminance: number;
}
return { export class ImageState implements Persistable<ImageConfigDTO> {
get fullImage(): string | null { #fullImage: string = $state("");
return fullImage; croppedImage: HTMLImageElement | undefined = $state(undefined);
}, masterImage: HTMLCanvasElement | undefined = $state(undefined);
cropRect: Rect = $state({ left: 0, top: 0, right: 0, bottom: 0 });
brightness: number = $state(IMAGE_SETTINGS.BRIGHTNESS_DEFAULT + 1);
contrast: number = $state(IMAGE_SETTINGS.CONTRAST_DEFAULT);
hue: number = $state(IMAGE_SETTINGS.HUE_DEFAULT);
saturation: number = $state(IMAGE_SETTINGS.SATURATION_DEFAULT);
luminance: number = $state(IMAGE_SETTINGS.LUMINANCE_DEFAULT);
isReady: boolean = $state(false);
appliedFilters: FilterConfig = $state({
brightness: 0,
contrast: 0,
hue: 0,
saturation: 0,
luminance: 0,
});
constructor() {
$effect.root(() => {
$effect(() => {
const brightness = this.brightness;
const contrast = this.contrast;
const hue = this.hue;
const saturation = this.saturation;
const luminance = this.luminance;
this.isReady = false;
const timeout = setTimeout(() => {
this.updateFilters({
hue,
saturation,
luminance,
brightness,
contrast,
});
}, DEBOUNCE_DURATION);
return () => clearTimeout(timeout);
});
});
}
updateFilters(newFilters: FilterConfig) {
this.appliedFilters = newFilters;
this.isReady = true;
}
reset() {
this.#fullImage = "";
this.croppedImage = undefined;
this.cropRect = { left: 0, top: 0, right: 0, bottom: 0 };
this.brightness = 0;
this.contrast = 0;
this.hue = 0;
}
get fullImage() {
return this.#fullImage;
}
set fullImage(image: string) { set fullImage(image: string) {
if (image) { if (image) {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
fullImage = image; this.#fullImage = image;
croppedImage = img; this.croppedImage = img;
}; };
img.src = image; img.src = image;
} }
}, }
get croppedImage() { toSnapshot(): ImageConfigDTO {
return croppedImage; return {
}, fullImage: this.#fullImage,
get cropRect() { cropRect: this.cropRect,
return crop; brightness: this.brightness,
}, contrast: this.contrast,
set cropRect(rect: Rect) { hue: this.hue,
crop = rect; saturation: this.saturation,
}, luminance: this.luminance,
reset() {
fullImage = null;
croppedImage = undefined;
crop = { left: 0, top: 0, right: 0, bottom: 0 };
},
}; };
}
fromSnapshot(data: Partial<ImageConfigDTO>): void {
for (const key of ImageConfigDTOKeys) {
if (key in data && data[key] !== undefined) {
(this as ImageConfigDTO)[key] = data[key] as never;
}
}
}
} }
export const imageState = createState(); export const imageState = withPersistence("image", new ImageState());
+3 -12
View File
@@ -1,16 +1,7 @@
import type { Stage } from "svelte-konva"; import type { Stage } from "svelte-konva";
function createState() { export class KonvaStage {
let stage: Stage | undefined = $state(undefined); stage: Stage | undefined = $state(undefined);
return {
get stage(): Stage | undefined {
return stage;
},
set stage(newStage: Stage) {
stage = newStage;
},
};
} }
export const konvaStageState = createState(); export const konvaStageState = new KonvaStage();
@@ -3,20 +3,26 @@
exports[`Application Constants Logic > Global Contract (Snapshot) > should match the previous configuration snapshot 1`] = ` exports[`Application Constants Logic > Global Contract (Snapshot) > should match the previous configuration snapshot 1`] = `
{ {
"IMAGE_SETTINGS": { "IMAGE_SETTINGS": {
"BRIGHTNESS_MAX": 100, "BRIGHTNESS_DEFAULT": 100,
"BRIGHTNESS_DIVIDER": 100,
"BRIGHTNESS_MAX": 200,
"BRIGHTNESS_MIN": 0, "BRIGHTNESS_MIN": 0,
"CHROMA_MAX": 300, "CONTRAST_DEFAULT": 0,
"CHROMA_MIN": 0, "CONTRAST_MAX": 100,
"CONTRAST_MAX": 150, "CONTRAST_MIN": -100,
"CONTRAST_MIN": 50,
"DEFAULT_BACKGROUND_IMAGE": "./backgrounds/b1.jpg", "DEFAULT_BACKGROUND_IMAGE": "./backgrounds/b1.jpg",
"DEFAULT_BRIGHTNESS": 100, "HUE_DEFAULT": 0,
"DEFAULT_CHROMA": 100,
"DEFAULT_CONTRAST": 100,
"DEFAULT_HUE": 0,
"HUE_MAX": 360, "HUE_MAX": 360,
"HUE_MIN": 0, "HUE_MIN": 0,
"LUMINANCE_DEFAULT": 0,
"LUMINANCE_DIVIDER": 100,
"LUMINANCE_MAX": 100,
"LUMINANCE_MIN": -100,
"MAX_FILE_SIZE": 10485760, "MAX_FILE_SIZE": 10485760,
"SATURATION_DEFAULT": 0,
"SATURATION_DIVIDER": 10,
"SATURATION_MAX": 50,
"SATURATION_MIN": -20,
"SUPPORTED_FORMATS": [ "SUPPORTED_FORMATS": [
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
@@ -1,12 +1,48 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
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", () => { describe("initial state", () => {
it("should render with collapsed state by default", () => {
render(SettingsGridTest); render(SettingsGridTest);
expect(screen.getByTestId("item1")).toBeInTheDocument(); expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
expect(screen.getByTestId("item2")).toBeInTheDocument(); expect(screen.queryByText("Item 2")).not.toBeInTheDocument();
expect(screen.getByTestId("item3")).toBeInTheDocument(); expect(screen.queryByText("Item 3")).not.toBeInTheDocument();
});
it("should display the label", () => {
render(SettingsGridTest);
expect(screen.getByText("Test Grid")).toBeInTheDocument();
});
it("should render expand button", () => {
render(SettingsGridTest);
const expandButton = screen.getByRole("button", { name: "Expand" });
expect(expandButton).toBeInTheDocument();
});
});
describe("expand/collapse behavior", () => {
it("should show children when expand button is clicked", async () => {
const user = userEvent.setup();
render(SettingsGridTest);
const expandButton = screen.getByRole("button", { name: "Expand" });
await user.click(expandButton);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("Item 3")).toBeInTheDocument();
});
it("should hide children when expand button is clicked twice", async () => {
const user = userEvent.setup();
render(SettingsGridTest);
const expandButton = screen.getByRole("button", { name: "Expand" });
await user.click(expandButton);
expect(screen.getByText("Item 1")).toBeInTheDocument();
await user.click(expandButton);
expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
});
}); });
}); });
@@ -1,8 +1,9 @@
<script> <script>
import SettingsGrid from "$components/layout/SettingsGrid.svelte"; import SettingsGrid from "$components/layout/SettingsGrid.svelte";
import Icon from "~icons/lucide/settings";
</script> </script>
<SettingsGrid> <SettingsGrid label="Test Grid" icon={Icon}>
<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>
+1 -1
View File
@@ -7,7 +7,7 @@ describe("imageState", () => {
}); });
it("should create new instance with default values", () => { it("should create new instance with default values", () => {
expect(imageState.fullImage).toBeNull(); expect(imageState.fullImage).toBe("");
expect(imageState.croppedImage).toBeUndefined(); expect(imageState.croppedImage).toBeUndefined();
expect(imageState.cropRect).toStrictEqual({ left: 0, top: 0, right: 0, bottom: 0 }); expect(imageState.cropRect).toStrictEqual({ left: 0, top: 0, right: 0, bottom: 0 });
}); });