feat: add image filters, update some logic and workflow
This commit is contained in:
+2
-3
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user