feat: update upload serive, start update image manager
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { imageState } from "$states/image.svelte";
|
||||
|
||||
let active = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="crop-canvas-container">
|
||||
<img src={imageState.fullImage} alt="Background for use" />
|
||||
<canvas class="crop-canvas"></canvas>
|
||||
<div class="crop-box" class:active>
|
||||
<div class="crop-handle nw"></div>
|
||||
|
||||
@@ -4,17 +4,25 @@
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
||||
// import Upload from "@lucide/svelte/icons/upload";
|
||||
import { IMAGE_SETTINGS } from "$lib/constants";
|
||||
import { uploadService } from "$services/uploadService";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import Pencil from "~icons/lucide/pencil";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Upload from "~icons/lucide/upload";
|
||||
import CropInline from "./CropInline.svelte";
|
||||
|
||||
let brightness = $state(100);
|
||||
let contrast = $state(100);
|
||||
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) {
|
||||
let image = await uploadService.fromClipboard(event);
|
||||
if (image.ok) {
|
||||
imageState.fullImage = image.data;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Фоновое изображение">
|
||||
@@ -22,22 +30,42 @@
|
||||
<CropInline />
|
||||
|
||||
<div class="crop-controls">
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="primary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Pencil} type="secondary" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Reset} type="outline" />
|
||||
</div>
|
||||
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Смещение цвета">
|
||||
<RangeSlider bind:value={hue} min={IMAGE_SETTINGS.HUE_MIN} max={IMAGE_SETTINGS.HUE_MAX} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Насыщенность">
|
||||
<RangeSlider
|
||||
bind:value={chroma}
|
||||
min={IMAGE_SETTINGS.CHROMA_MIN}
|
||||
max={IMAGE_SETTINGS.CHROMA_MAX}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Яркость">
|
||||
<RangeSlider bind:value={brightness} />
|
||||
<RangeSlider
|
||||
bind:value={brightness}
|
||||
min={IMAGE_SETTINGS.BRIGHTNESS_MIN}
|
||||
max={IMAGE_SETTINGS.BRIGHTNESS_MAX}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Контраст">
|
||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
||||
<RangeSlider
|
||||
bind:value={contrast}
|
||||
min={IMAGE_SETTINGS.CONTRAST_MIN}
|
||||
max={IMAGE_SETTINGS.CONTRAST_MAX}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<svelte:window onpaste={handlePaste} />
|
||||
|
||||
<style>
|
||||
.crop-editor {
|
||||
display: flex;
|
||||
|
||||
+19
-2
@@ -5,8 +5,6 @@ export const PANEL_SETTINGS = {
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 200,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
} as const;
|
||||
|
||||
// ===== TYPOGRAPHY =====
|
||||
@@ -33,6 +31,7 @@ export const TYPOGRAPHY = {
|
||||
|
||||
// Padding for range inputs
|
||||
PADDING_X_DEFAULT: 10,
|
||||
PADDING_X_MIN: 0,
|
||||
PADDING_X_MAX: 100,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
@@ -48,6 +47,24 @@ export const IMAGE_SETTINGS = {
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
|
||||
DEFAULT_HUE: 0,
|
||||
HUE_MIN: 0,
|
||||
HUE_MAX: 360,
|
||||
|
||||
DEFAULT_CHROMA: 100,
|
||||
CHROMA_MIN: 0,
|
||||
CHROMA_MAX: 300,
|
||||
|
||||
DEFAULT_BRIGHTNESS: 100,
|
||||
BRIGHTNESS_MIN: 0,
|
||||
BRIGHTNESS_MAX: 100,
|
||||
|
||||
DEFAULT_CONTRAST: 100,
|
||||
CONTRAST_MIN: 50,
|
||||
CONTRAST_MAX: 150,
|
||||
} as const;
|
||||
|
||||
export const SlideDirection = {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { IMAGE_SETTINGS } from "$lib/constants";
|
||||
import { uploadService } from "$services/uploadService";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import "../app.css";
|
||||
@@ -18,7 +19,12 @@
|
||||
let { children }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
let defaultImage = await uploadService.fromUrl(IMAGE_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
if (defaultImage.ok) {
|
||||
imageState.fullImage = defaultImage.data;
|
||||
} else {
|
||||
console.error("Failed to load default image");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export type UploadResult =
|
||||
| { ok: true; data: string; error?: never }
|
||||
| { ok: false; error: string; data?: never };
|
||||
|
||||
export const uploadService = {
|
||||
async _process(file: File): Promise<UploadResult> {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return { ok: false, error: "Not an image" };
|
||||
}
|
||||
try {
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const img = new Image();
|
||||
img.src = base64;
|
||||
await img.decode();
|
||||
|
||||
return { ok: true, data: base64 };
|
||||
} catch {
|
||||
return { ok: false, error: "File damage or unsupported" };
|
||||
}
|
||||
},
|
||||
|
||||
async fromFile(file: File | undefined): Promise<UploadResult> {
|
||||
if (!file) return { ok: false, error: "No file" };
|
||||
return this._process(file);
|
||||
},
|
||||
|
||||
async fromClipboard(event: ClipboardEvent): Promise<UploadResult> {
|
||||
const file = event.clipboardData?.files[0];
|
||||
return this.fromFile(file);
|
||||
},
|
||||
|
||||
async fromUrl(url: string): Promise<UploadResult> {
|
||||
if (!url) return { ok: false, error: "No url" };
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return { ok: false, error: "Invalid url" };
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "image", { type: blob.type });
|
||||
return this._process(file);
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid url, or CORS error" };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export type ImageConfig = {
|
||||
fullImage: string | null;
|
||||
croppedImage: HTMLImageElement | undefined;
|
||||
cropRect: Rect;
|
||||
};
|
||||
|
||||
function createState(): ImageConfig & { reset: () => void } {
|
||||
let fullImage = $state<string | null>(null);
|
||||
let croppedImage = $state<HTMLImageElement | undefined>(undefined);
|
||||
let crop: Rect = $state({ left: 0, top: 0, right: 0, bottom: 0 });
|
||||
|
||||
return {
|
||||
get fullImage(): string | null {
|
||||
return fullImage;
|
||||
},
|
||||
set fullImage(image: string) {
|
||||
if (image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
fullImage = image;
|
||||
croppedImage = img;
|
||||
};
|
||||
img.src = image;
|
||||
}
|
||||
},
|
||||
get croppedImage() {
|
||||
return croppedImage;
|
||||
},
|
||||
get cropRect() {
|
||||
return crop;
|
||||
},
|
||||
set cropRect(rect: Rect) {
|
||||
crop = rect;
|
||||
},
|
||||
reset() {
|
||||
fullImage = null;
|
||||
croppedImage = undefined;
|
||||
crop = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const imageState = createState();
|
||||
@@ -1,98 +0,0 @@
|
||||
export type ImageConfig = {
|
||||
image: HTMLImageElement | undefined;
|
||||
imageLink: string;
|
||||
imageReady: boolean;
|
||||
cropLeft: number;
|
||||
cropTop: number;
|
||||
cropRight: number;
|
||||
cropBottom: number;
|
||||
};
|
||||
|
||||
export class ImageConfigState {
|
||||
image = $state<HTMLImageElement | undefined>(undefined);
|
||||
imageLink = $state("");
|
||||
imageReady = $state(false);
|
||||
cropLeft = $state(0);
|
||||
cropTop = $state(0);
|
||||
cropRight = $state(0);
|
||||
cropBottom = $state(0);
|
||||
|
||||
#currentAbortController: AbortController | null = null;
|
||||
|
||||
private cleanup() {
|
||||
if (this.#currentAbortController) {
|
||||
this.#currentAbortController.abort();
|
||||
this.#currentAbortController = null;
|
||||
}
|
||||
|
||||
if (this.image) {
|
||||
this.image.onload = null;
|
||||
this.image.onerror = null;
|
||||
this.image.src = "";
|
||||
this.image = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageByLink(link: string): Promise<void> {
|
||||
this.cleanup();
|
||||
|
||||
this.imageReady = false;
|
||||
this.imageLink = link;
|
||||
|
||||
this.#currentAbortController = new AbortController();
|
||||
const { signal } = this.#currentAbortController;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
const onFinished = () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
if (signal.aborted) return;
|
||||
onFinished();
|
||||
this.image = img;
|
||||
this.imageReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (signal.aborted) return;
|
||||
onFinished();
|
||||
this.imageReady = false;
|
||||
reject(new Error(`Failed to load image: ${link}`));
|
||||
};
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
onFinished();
|
||||
img.src = "";
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
img.src = link;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.cleanup();
|
||||
this.imageLink = "";
|
||||
this.imageReady = false;
|
||||
this.cropLeft = 0;
|
||||
this.cropTop = 0;
|
||||
this.cropRight = 0;
|
||||
this.cropBottom = 0;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export const imageConfigState = new ImageConfigState();
|
||||
Reference in New Issue
Block a user