3810 lines
97 KiB
Vue
3810 lines
97 KiB
Vue
favorite-colors-grid<template>
|
|
<div
|
|
class="add-background-page"
|
|
@dragenter.prevent="handleDragEnter"
|
|
@dragover.prevent="handleDragOver"
|
|
@dragleave="handleDragLeave"
|
|
@drop.prevent="handleDrop"
|
|
>
|
|
<div v-if="isDragging" class="global-drag-overlay">
|
|
<div class="overlay-content">
|
|
<p>{{ t('Drop image anywhere to add background') }}</p>
|
|
</div>
|
|
</div>
|
|
<input
|
|
ref="fileInputRef"
|
|
type="file"
|
|
accept="image/*"
|
|
style="display: none"
|
|
@change="handleFileSelect"
|
|
/>
|
|
|
|
<div class="page-header" :class="{ 'has-sidebar': uploadedImage }">
|
|
<h2>{{ t('Add Background') }}</h2>
|
|
<!-- Color Shades in Header Center -->
|
|
<div v-if="uploadedImage" class="header-color-shades">
|
|
<div
|
|
v-for="(shade, index) in colorShades"
|
|
:key="index"
|
|
class="shade-item"
|
|
:class="{ 'active': backgroundColor && shade === backgroundColor }"
|
|
:style="{ backgroundColor: shade }"
|
|
@click="selectShade(shade)"
|
|
:title="shade"
|
|
>
|
|
</div>
|
|
</div>
|
|
<div v-if="uploadedImage" class="toolbar-actions">
|
|
<button
|
|
v-if="resultImage"
|
|
class="toolbar-btn"
|
|
@click="handleDownload"
|
|
:title="t('Download')"
|
|
>
|
|
<i class="fa fa-download"></i>
|
|
</button>
|
|
<button
|
|
v-if="resultImage"
|
|
class="toolbar-btn"
|
|
@click="clearBackground"
|
|
:title="t('Clear Background')"
|
|
>
|
|
<i class="fa fa-eraser"></i>
|
|
</button>
|
|
<button
|
|
class="toolbar-btn"
|
|
@click="resetUpload"
|
|
:title="t('Change Image')"
|
|
>
|
|
<i class="fa fa-refresh"></i>
|
|
</button>
|
|
<button
|
|
class="toolbar-btn mobile-color-picker-btn"
|
|
@click="toggleMobileColorPicker"
|
|
:title="t('Color Picker')"
|
|
>
|
|
<i class="fa fa-palette"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page-content" :class="{ 'has-sidebar': uploadedImage }">
|
|
<div class="tool-container">
|
|
<div class="main-area">
|
|
<div class="upload-section">
|
|
<div
|
|
v-if="!uploadedImage"
|
|
class="upload-area"
|
|
:class="{ 'dragging': isDragging }"
|
|
>
|
|
<div class="upload-content">
|
|
<button
|
|
type="button"
|
|
class="upload-btn"
|
|
@click="triggerFileInput"
|
|
:disabled="processing"
|
|
>
|
|
<i class="fa fa-upload"></i>
|
|
<span>{{ t('Upload Image') }}</span>
|
|
</button>
|
|
|
|
<div class="divider">
|
|
<span>{{ t('or') }}</span>
|
|
</div>
|
|
|
|
<div class="url-input-wrapper" @click.stop>
|
|
<input
|
|
ref="urlInputRef"
|
|
v-model="imageUrl"
|
|
type="text"
|
|
class="url-input"
|
|
:placeholder="t('Paste image URL here')"
|
|
@keyup.enter="handleUrlSubmit"
|
|
@paste="handleUrlPaste"
|
|
:disabled="processing"
|
|
/>
|
|
</div>
|
|
|
|
<p class="upload-hint">{{ t('Drag and drop your image anywhere, or paste image directly') }}</p>
|
|
<p class="upload-format-hint">{{ t('Supports JPG, PNG, WebP formats') }}</p>
|
|
</div>
|
|
<div v-if="processing" class="upload-processing-overlay">
|
|
<div class="spinner"></div>
|
|
<p>{{ t('Processing...') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="preview-section">
|
|
<div class="canvas-container" v-show="uploadedImage">
|
|
<canvas
|
|
ref="canvasRef"
|
|
class="preview-canvas"
|
|
></canvas>
|
|
<div v-if="processing" class="canvas-processing-overlay">
|
|
<div class="spinner"></div>
|
|
<p>{{ t('Applying background...') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="historyList.length > 0 || uploadedImage" class="history-bar">
|
|
<div class="history-scroll-container">
|
|
<button
|
|
type="button"
|
|
class="history-item add-button"
|
|
@click.stop="triggerFileInput"
|
|
:title="t('Add New Image')"
|
|
>
|
|
<i class="fa fa-plus"></i>
|
|
</button>
|
|
|
|
<div
|
|
v-for="(item, index) in historyList"
|
|
:key="item.id"
|
|
class="history-item"
|
|
:class="{ 'active': currentHistoryIndex === index }"
|
|
@click="selectHistoryItem(index)"
|
|
>
|
|
<div class="history-thumbnail">
|
|
<img :src="getHistoryThumbnailUrl(item)" alt="History" />
|
|
<button
|
|
type="button"
|
|
class="history-delete-btn"
|
|
@click.stop="removeHistoryItem(index)"
|
|
:title="t('Delete')"
|
|
:aria-label="t('Delete')"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="uploadedImage && resultImage && currentHistoryIndex === -1"
|
|
class="history-item active"
|
|
>
|
|
<div class="history-thumbnail">
|
|
<img :src="resultImageUrl" alt="Current" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Bottom Sheet Color Picker -->
|
|
<transition name="slide-up">
|
|
<div
|
|
v-if="uploadedImage && showMobileColorPicker"
|
|
class="mobile-color-sheet-overlay"
|
|
@click="closeMobileColorPicker"
|
|
>
|
|
<div
|
|
class="mobile-color-sheet"
|
|
@click.stop
|
|
>
|
|
<div class="sheet-handle"></div>
|
|
<div class="sheet-header">
|
|
<h4>{{ t('Background Color') }}</h4>
|
|
<button
|
|
class="sheet-close-btn"
|
|
@click="closeMobileColorPicker"
|
|
:title="t('Close')"
|
|
>
|
|
<i class="fa fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="sheet-content">
|
|
<div class="color-picker-section">
|
|
<div class="color-picker-container">
|
|
<input
|
|
v-model="backgroundColor"
|
|
type="color"
|
|
class="sidebar-color-picker"
|
|
@input="onColorChange"
|
|
/>
|
|
<div class="hex-input-wrapper">
|
|
<input
|
|
v-model="backgroundColor"
|
|
type="text"
|
|
class="sidebar-hex-input"
|
|
placeholder=""
|
|
@input="onHexInputChange"
|
|
@blur="onHexInputBlur"
|
|
/>
|
|
<button
|
|
class="add-favorite-btn"
|
|
@click="addToFavorites"
|
|
:title="t('Add to favorites')"
|
|
:disabled="favoriteColors.length >= MAX_FAVORITE_COLORS"
|
|
>
|
|
<i class="fa fa-star"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="favoriteColors.length > 0" class="favorite-colors-section">
|
|
<h4 class="section-title">{{ t('Favorite Colors') }}</h4>
|
|
<div class="favorite-colors-grid">
|
|
<div
|
|
v-for="(color, index) in favoriteColors"
|
|
:key="index"
|
|
class="favorite-color-item"
|
|
:class="{ 'active': color === backgroundColor }"
|
|
:style="{ backgroundColor: color }"
|
|
@click="selectCommonColor(color)"
|
|
:title="color"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="remove-favorite-btn"
|
|
@click.stop="removeFavoriteColor(color)"
|
|
:title="t('Remove from favorites')"
|
|
:aria-label="t('Remove from favorites')"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="common-colors-section">
|
|
<h4 class="section-title">{{ t('Common Colors') }}</h4>
|
|
<div class="common-colors-grid">
|
|
<div
|
|
v-for="(color, index) in commonColors"
|
|
:key="index"
|
|
class="common-color-item"
|
|
:class="{ 'active': color === backgroundColor }"
|
|
:style="{ backgroundColor: color }"
|
|
@click="selectCommonColor(color)"
|
|
:title="color"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Mobile Bottom Sheet Color Picker -->
|
|
<transition name="slide-up">
|
|
<div
|
|
v-if="uploadedImage && showMobileColorPicker"
|
|
class="mobile-color-sheet-overlay"
|
|
@click="closeMobileColorPicker"
|
|
>
|
|
<div
|
|
class="mobile-color-sheet"
|
|
@click.stop
|
|
>
|
|
<div class="sheet-handle"></div>
|
|
<div class="sheet-header">
|
|
<h4>{{ t('Background Settings') }}</h4>
|
|
<button
|
|
class="sheet-close-btn"
|
|
@click="closeMobileColorPicker"
|
|
:title="t('Close')"
|
|
>
|
|
<i class="fa fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="sheet-content">
|
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
|
<n-tab-pane name="color" :tab="t('Color')">
|
|
<div class="color-picker-section">
|
|
<div class="color-picker-container">
|
|
<!-- Show checkerboard when background is cleared -->
|
|
<div
|
|
v-if="!backgroundColor"
|
|
class="sidebar-color-picker-empty"
|
|
@click="triggerColorPicker"
|
|
>
|
|
</div>
|
|
<input
|
|
v-if="backgroundColor"
|
|
v-model="backgroundColor"
|
|
type="color"
|
|
class="sidebar-color-picker"
|
|
@input="onColorChange"
|
|
/>
|
|
<div class="hex-input-wrapper">
|
|
<input
|
|
v-model="backgroundColor"
|
|
type="text"
|
|
class="sidebar-hex-input"
|
|
placeholder=""
|
|
@input="onHexInputChange"
|
|
@blur="onHexInputBlur"
|
|
/>
|
|
<button
|
|
class="add-favorite-btn"
|
|
@click="addToFavorites"
|
|
:title="t('Add to favorites')"
|
|
:disabled="!backgroundColor || favoriteColors.length >= MAX_FAVORITE_COLORS"
|
|
>
|
|
<i class="fa fa-star"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="favoriteColors.length > 0" class="favorite-colors-section">
|
|
<h4 class="section-title">{{ t('Favorite Colors') }}</h4>
|
|
<div class="favorite-colors-grid">
|
|
<div
|
|
v-for="(color, index) in favoriteColors"
|
|
:key="index"
|
|
class="favorite-color-item"
|
|
:class="{ 'active': color === backgroundColor }"
|
|
:style="{ backgroundColor: color }"
|
|
@click="selectCommonColor(color)"
|
|
:title="color"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="remove-favorite-btn"
|
|
@click.stop="removeFavoriteColor(color)"
|
|
:title="t('Remove from favorites')"
|
|
:aria-label="t('Remove from favorites')"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="common-colors-section">
|
|
<h4 class="section-title">{{ t('Common Colors') }}</h4>
|
|
<div class="common-colors-grid">
|
|
<div
|
|
v-for="(color, index) in commonColors"
|
|
:key="index"
|
|
class="common-color-item"
|
|
:class="{ 'active': color === backgroundColor }"
|
|
:style="{ backgroundColor: color }"
|
|
@click="selectCommonColor(color)"
|
|
:title="color"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</n-tab-pane>
|
|
|
|
<n-tab-pane name="image" :tab="t('Background')">
|
|
<div class="background-images-section">
|
|
<h4 class="section-title">{{ t('Upload Custom Background') }}</h4>
|
|
<n-upload
|
|
:custom-request="handleCustomBackgroundUpload"
|
|
accept="image/*"
|
|
:show-file-list="false"
|
|
>
|
|
<n-upload-dragger>
|
|
<div class="upload-dragger-content">
|
|
<i class="fa fa-cloud-upload" style="font-size: 32px; color: #1fc76f;"></i>
|
|
<div class="upload-text">{{ t('Click or drag to upload') }}</div>
|
|
</div>
|
|
</n-upload-dragger>
|
|
</n-upload>
|
|
</div>
|
|
|
|
<div v-if="customBackgrounds.length > 0" class="custom-backgrounds-section">
|
|
<h4 class="section-title">{{ t('My Backgrounds') }}</h4>
|
|
<div class="background-images-grid">
|
|
<div
|
|
v-for="(bgImage, index) in customBackgrounds"
|
|
:key="index"
|
|
class="background-image-item"
|
|
@click="applyBackgroundImage(bgImage)"
|
|
>
|
|
<img :src="bgImage" alt="Background" />
|
|
<button
|
|
type="button"
|
|
class="remove-bg-btn"
|
|
@click.stop="removeCustomBackground(index)"
|
|
:title="t('Remove')"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pexels-images-section">
|
|
<h4 class="section-title">{{ t('Pexels Backgrounds') }}</h4>
|
|
<div class="pexels-search-box">
|
|
<input
|
|
v-model="pexelsSearchInput"
|
|
type="text"
|
|
class="pexels-search-input"
|
|
:placeholder="t('Search backgrounds...')"
|
|
@keyup.enter="searchPexelsImages"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="pexels-search-btn"
|
|
@click="searchPexelsImages"
|
|
:disabled="pexelsLoading"
|
|
>
|
|
<i class="fa fa-search"></i>
|
|
</button>
|
|
</div>
|
|
<n-spin :show="pexelsLoading">
|
|
<div v-if="pexelsImages.length > 0" class="background-images-grid">
|
|
<div
|
|
v-for="image in displayedPexelsImages"
|
|
:key="image.id"
|
|
class="background-image-item"
|
|
@click="applyBackgroundImage(image.src.medium)"
|
|
>
|
|
<img :src="image.src.tiny" :alt="image.alt" />
|
|
</div>
|
|
</div>
|
|
<div v-if="showLoadMoreButton" class="load-more-container">
|
|
<button
|
|
type="button"
|
|
class="load-more-btn"
|
|
@click="loadMorePexelsImages"
|
|
>
|
|
{{ t('Show More') }}
|
|
</button>
|
|
</div>
|
|
<n-empty v-else-if="pexelsImages.length === 0" :description="t('No images found')" size="small" />
|
|
</n-spin>
|
|
</div>
|
|
</n-tab-pane>
|
|
</n-tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Right Sidebar for Common Colors and Color Picker (Desktop Only) -->
|
|
<div v-if="uploadedImage" class="right-sidebar desktop-only">
|
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
|
<n-tab-pane name="color" :tab="t('Color')">
|
|
<div class="sidebar-content">
|
|
<div class="color-picker-section">
|
|
<h4 class="section-title">{{ t('Background Color') }}</h4>
|
|
<div class="color-picker-container">
|
|
<!-- Show checkerboard when background is cleared -->
|
|
<div
|
|
v-if="!backgroundColor"
|
|
class="sidebar-color-picker-empty"
|
|
@click="triggerColorPicker"
|
|
>
|
|
</div>
|
|
<input
|
|
v-if="backgroundColor"
|
|
v-model="backgroundColor"
|
|
type="color"
|
|
class="sidebar-color-picker"
|
|
@input="onColorChange"
|
|
/>
|
|
<div class="hex-input-wrapper">
|
|
<input
|
|
v-model="backgroundColor"
|
|
type="text"
|
|
class="sidebar-hex-input"
|
|
placeholder=""
|
|
@input="onHexInputChange"
|
|
@blur="onHexInputBlur"
|
|
/>
|
|
<button
|
|
class="add-favorite-btn"
|
|
@click="addToFavorites"
|
|
:title="t('Add to favorites')"
|
|
:disabled="!backgroundColor || favoriteColors.length >= MAX_FAVORITE_COLORS"
|
|
>
|
|
<i class="fa fa-star"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="favoriteColors.length > 0" class="favorite-colors-section">
|
|
<h4 class="section-title">{{ t('Favorite Colors') }}</h4>
|
|
<div class="favorite-colors-grid">
|
|
<div
|
|
v-for="(color, index) in favoriteColors"
|
|
:key="index"
|
|
class="favorite-color-item"
|
|
:class="{ 'active': color === backgroundColor }"
|
|
:style="{ backgroundColor: color }"
|
|
@click="selectCommonColor(color)"
|
|
:title="color"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="remove-favorite-btn"
|
|
@click.stop="removeFavoriteColor(color)"
|
|
:title="t('Remove from favorites')"
|
|
:aria-label="t('Remove from favorites')"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="common-colors-section">
|
|
<h4 class="section-title">{{ t('Common Colors') }}</h4>
|
|
<div class="common-colors-grid">
|
|
<div
|
|
v-for="(color, index) in commonColors"
|
|
:key="index"
|
|
class="common-color-item"
|
|
:class="{ 'active': color === backgroundColor }"
|
|
:style="{ backgroundColor: color }"
|
|
@click="selectCommonColor(color)"
|
|
:title="color"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</n-tab-pane>
|
|
|
|
<n-tab-pane name="image" :tab="t('Background Image')">
|
|
<div class="sidebar-content">
|
|
<div class="background-images-section">
|
|
<h4 class="section-title">{{ t('Upload Custom Background') }}</h4>
|
|
<n-upload
|
|
:custom-request="handleCustomBackgroundUpload"
|
|
accept="image/*"
|
|
:show-file-list="false"
|
|
>
|
|
<n-upload-dragger>
|
|
<div class="upload-dragger-content">
|
|
<i class="fa fa-cloud-upload" style="font-size: 32px; color: #1fc76f;"></i>
|
|
<div class="upload-text">{{ t('Click or drag to upload') }}</div>
|
|
</div>
|
|
</n-upload-dragger>
|
|
</n-upload>
|
|
</div>
|
|
|
|
<div v-if="customBackgrounds.length > 0" class="custom-backgrounds-section">
|
|
<h4 class="section-title">{{ t('My Backgrounds') }}</h4>
|
|
<div class="background-images-grid">
|
|
<div
|
|
v-for="(bgImage, index) in customBackgrounds"
|
|
:key="index"
|
|
class="background-image-item"
|
|
@click="applyBackgroundImage(bgImage)"
|
|
>
|
|
<img :src="bgImage" alt="Background" />
|
|
<button
|
|
type="button"
|
|
class="remove-bg-btn"
|
|
@click.stop="removeCustomBackground(index)"
|
|
:title="t('Remove')"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pexels-images-section">
|
|
<h4 class="section-title">{{ t('Pexels Backgrounds') }}</h4>
|
|
<div class="pexels-search-box">
|
|
<input
|
|
v-model="pexelsSearchInput"
|
|
type="text"
|
|
class="pexels-search-input"
|
|
:placeholder="t('Search backgrounds...')"
|
|
@keyup.enter="searchPexelsImages"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="pexels-search-btn"
|
|
@click="searchPexelsImages"
|
|
:disabled="pexelsLoading"
|
|
>
|
|
<i class="fa fa-search"></i>
|
|
</button>
|
|
</div>
|
|
<n-spin :show="pexelsLoading">
|
|
<div v-if="pexelsImages.length > 0" class="background-images-grid">
|
|
<div
|
|
v-for="image in displayedPexelsImages"
|
|
:key="image.id"
|
|
class="background-image-item"
|
|
@click="applyBackgroundImage(image.src.medium)"
|
|
>
|
|
<img :src="image.src.tiny" :alt="image.alt" />
|
|
</div>
|
|
</div>
|
|
<div v-if="showLoadMoreButton" class="load-more-container">
|
|
<button
|
|
type="button"
|
|
class="load-more-btn"
|
|
@click="loadMorePexelsImages"
|
|
>
|
|
{{ t('Show More') }}
|
|
</button>
|
|
</div>
|
|
<n-empty v-else-if="pexelsImages.length === 0" :description="t('No images found')" />
|
|
</n-spin>
|
|
</div>
|
|
</div>
|
|
</n-tab-pane>
|
|
</n-tabs>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { useMessage, NTabs, NTabPane, NSpin, NUpload, NUploadDragger, NButton, NEmpty } from 'naive-ui'
|
|
import { useRoute } from 'vue-router'
|
|
import axios from 'axios'
|
|
import { t } from '@/shared/i18n'
|
|
import { get_session_api_headers } from '@/shared/api/auth'
|
|
import { useAuthStore } from '@/shared/stores/auth'
|
|
import { useSEO } from '@/shared/composables/useSEO'
|
|
import { compressImageFile } from '@/shared/utils/imageResize'
|
|
import { Canvas, FabricImage } from 'fabric'
|
|
import ColorThief from 'colorthief'
|
|
|
|
const message = useMessage()
|
|
const authStore = useAuthStore()
|
|
const route = useRoute()
|
|
|
|
// SEO configuration
|
|
useSEO({
|
|
title: t('Add Background'),
|
|
description: t('Add custom background color to images. Free online tool to add background to transparent images. Supports JPG, PNG, WebP formats.'),
|
|
keywords: t('add background, background color, image background, transparent background, online background tool, image processing, background editor, free tool')
|
|
})
|
|
|
|
interface HistoryItem {
|
|
id: string
|
|
originalImageUrl: string
|
|
originalImageFile: File | null
|
|
resultImage: string
|
|
backgroundColor: string
|
|
timestamp: number
|
|
}
|
|
|
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
const urlInputRef = ref<HTMLInputElement | null>(null)
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
const uploadedImage = ref<File | null>(null)
|
|
const uploadedImageUrl = ref<string>('')
|
|
const resultImage = ref<string>('')
|
|
const resultImageBlobUrl = ref<string>('')
|
|
const imageUrl = ref<string>('')
|
|
const backgroundColor = ref<string>('#FFFFFF')
|
|
const isColorValid = ref<boolean>(true)
|
|
let colorChangeDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
const colorThief = new ColorThief()
|
|
|
|
// Generate 11 color shades based on current background color (current color in middle)
|
|
const colorShades = computed(() => {
|
|
return generateColorShades(backgroundColor.value)
|
|
})
|
|
|
|
// Favorite colors - persisted in localStorage
|
|
const favoriteColors = ref<string[]>([])
|
|
const MAX_FAVORITE_COLORS = 12
|
|
|
|
// Load favorite colors from localStorage
|
|
const loadFavoriteColors = () => {
|
|
try {
|
|
const saved = localStorage.getItem('add_background_favorite_colors')
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved)
|
|
if (Array.isArray(parsed)) {
|
|
favoriteColors.value = parsed.slice(0, MAX_FAVORITE_COLORS)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load favorite colors:', error)
|
|
}
|
|
}
|
|
|
|
// Save favorite colors to localStorage
|
|
const saveFavoriteColors = () => {
|
|
try {
|
|
localStorage.setItem('add_background_favorite_colors', JSON.stringify(favoriteColors.value))
|
|
} catch (error) {
|
|
console.error('Failed to save favorite colors:', error)
|
|
}
|
|
}
|
|
|
|
// Add current color to favorites
|
|
const addToFavorites = () => {
|
|
const color = backgroundColor.value.toUpperCase()
|
|
|
|
// Check if color already exists
|
|
if (favoriteColors.value.includes(color)) {
|
|
message.info(t('Color already in favorites'))
|
|
return
|
|
}
|
|
|
|
// Check if favorites is full
|
|
if (favoriteColors.value.length >= MAX_FAVORITE_COLORS) {
|
|
message.warning(t('Maximum 12 favorite colors allowed. Please remove one first.'))
|
|
return
|
|
}
|
|
|
|
// Add to beginning of array
|
|
favoriteColors.value.unshift(color)
|
|
saveFavoriteColors()
|
|
message.success(t('Color added to favorites'))
|
|
}
|
|
|
|
// Remove color from favorites
|
|
const removeFavoriteColor = (color: string) => {
|
|
const index = favoriteColors.value.indexOf(color)
|
|
if (index > -1) {
|
|
favoriteColors.value.splice(index, 1)
|
|
saveFavoriteColors()
|
|
}
|
|
}
|
|
|
|
// Trigger color picker when clicking on empty checkerboard
|
|
const triggerColorPicker = (event: MouseEvent) => {
|
|
// Get the position of the checkerboard element
|
|
const targetElement = event.currentTarget as HTMLElement
|
|
const rect = targetElement.getBoundingClientRect()
|
|
|
|
// Create a temporary color input at the checkerboard position
|
|
const tempInput = document.createElement('input')
|
|
tempInput.type = 'color'
|
|
tempInput.value = '#FFFFFF'
|
|
tempInput.style.position = 'fixed'
|
|
tempInput.style.left = `${rect.left}px`
|
|
tempInput.style.top = `${rect.top}px`
|
|
tempInput.style.width = `${rect.width}px`
|
|
tempInput.style.height = `${rect.height}px`
|
|
tempInput.style.opacity = '0'
|
|
tempInput.style.border = 'none'
|
|
tempInput.style.cursor = 'pointer'
|
|
|
|
document.body.appendChild(tempInput)
|
|
|
|
tempInput.addEventListener('input', (e) => {
|
|
const target = e.target as HTMLInputElement
|
|
backgroundColor.value = target.value
|
|
onColorChange()
|
|
})
|
|
|
|
tempInput.addEventListener('change', () => {
|
|
setTimeout(() => {
|
|
document.body.removeChild(tempInput)
|
|
}, 100)
|
|
})
|
|
|
|
// Trigger click to open color picker
|
|
tempInput.click()
|
|
}
|
|
|
|
// Fetch Pexels images
|
|
const fetchPexelsImages = async () => {
|
|
// Skip fetching if no API key is configured
|
|
if (!PEXELS_API_KEY) {
|
|
console.warn('Pexels API key not configured. Skipping image fetch.')
|
|
pexelsImages.value = []
|
|
pexelsLoading.value = false
|
|
pexelsHasMore.value = false
|
|
return
|
|
}
|
|
|
|
pexelsLoading.value = true
|
|
try {
|
|
const response = await axios.get('https://api.pexels.com/v1/search', {
|
|
params: {
|
|
query: pexelsQuery.value,
|
|
page: pexelsPage.value,
|
|
per_page: PEXELS_PER_PAGE,
|
|
orientation: 'landscape',
|
|
locale: 'zh-CN' // Add locale for better Chinese search results
|
|
},
|
|
headers: {
|
|
Authorization: PEXELS_API_KEY
|
|
},
|
|
// Ensure proper encoding of parameters
|
|
paramsSerializer: {
|
|
encode: (param) => encodeURIComponent(param)
|
|
}
|
|
})
|
|
const newPhotos = response.data.photos || []
|
|
|
|
// Append new photos to existing list
|
|
if (pexelsPage.value === 1) {
|
|
pexelsImages.value = newPhotos
|
|
} else {
|
|
pexelsImages.value = [...pexelsImages.value, ...newPhotos]
|
|
}
|
|
|
|
// If we got fewer images than requested, there are no more images
|
|
pexelsHasMore.value = newPhotos.length >= PEXELS_PER_PAGE
|
|
} catch (error: any) {
|
|
console.error('Failed to fetch Pexels images:', error)
|
|
pexelsHasMore.value = false
|
|
// Don't show error message if API key is not configured
|
|
if (PEXELS_API_KEY) {
|
|
message.error(t('Failed to load background images'))
|
|
}
|
|
} finally {
|
|
pexelsLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Apply background image
|
|
const applyBackgroundImage = async (imageUrl: string) => {
|
|
if (!fabricCanvas || !uploadedImage.value || !uploadedImageUrl.value) return
|
|
|
|
try {
|
|
processing.value = true
|
|
|
|
// Save the original foreground image reference before clearing
|
|
const currentObjects = fabricCanvas.getObjects()
|
|
const foregroundImg = currentObjects.length > 0 ? currentObjects[0] : null
|
|
|
|
// Load background image
|
|
const bgImg = await FabricImage.fromURL(imageUrl, {
|
|
crossOrigin: 'anonymous'
|
|
})
|
|
|
|
if (!bgImg || !fabricCanvas) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
// Get canvas dimensions
|
|
const canvasWidth = fabricCanvas.width || 1
|
|
const canvasHeight = fabricCanvas.height || 1
|
|
|
|
// Scale background image to cover canvas
|
|
const bgScale = Math.max(
|
|
canvasWidth / (bgImg.width || 1),
|
|
canvasHeight / (bgImg.height || 1)
|
|
)
|
|
|
|
bgImg.set({
|
|
scaleX: bgScale,
|
|
scaleY: bgScale,
|
|
left: canvasWidth / 2,
|
|
top: canvasHeight / 2,
|
|
originX: 'center',
|
|
originY: 'center',
|
|
selectable: false,
|
|
evented: false
|
|
})
|
|
|
|
// Clear canvas
|
|
fabricCanvas.clear()
|
|
|
|
// Add background image first (at the bottom)
|
|
fabricCanvas.add(bgImg)
|
|
|
|
// Re-load and add the foreground image (original uploaded image)
|
|
if (foregroundImg) {
|
|
// Clone the foreground image to avoid issues
|
|
const newForegroundImg = await FabricImage.fromURL(uploadedImageUrl.value, {
|
|
crossOrigin: 'anonymous'
|
|
})
|
|
|
|
if (newForegroundImg) {
|
|
newForegroundImg.set({
|
|
left: canvasWidth / 2,
|
|
top: canvasHeight / 2,
|
|
originX: 'center',
|
|
originY: 'center',
|
|
selectable: false,
|
|
evented: false
|
|
})
|
|
|
|
fabricCanvas.add(newForegroundImg)
|
|
}
|
|
}
|
|
|
|
// Ensure correct layer order: background at bottom, foreground on top
|
|
const allObjects = fabricCanvas.getObjects()
|
|
if (allObjects.length === 2) {
|
|
// Move background (first object) to back
|
|
fabricCanvas.sendObjectToBack(allObjects[0])
|
|
// Move foreground (second object) to front
|
|
fabricCanvas.bringObjectToFront(allObjects[1])
|
|
}
|
|
|
|
fabricCanvas.renderAll()
|
|
|
|
// Export result
|
|
const dataUrl = fabricCanvas.toDataURL({
|
|
format: 'png',
|
|
quality: 0.95,
|
|
multiplier: 1
|
|
})
|
|
|
|
resultImage.value = dataUrl
|
|
await cacheResultImage(dataUrl)
|
|
|
|
// Update history
|
|
if (currentHistoryIndex.value === -1 && uploadedImage.value && uploadedImageUrl.value) {
|
|
const historyItem: HistoryItem = {
|
|
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
originalImageUrl: uploadedImageUrl.value,
|
|
originalImageFile: uploadedImage.value,
|
|
resultImage: dataUrl,
|
|
backgroundColor: backgroundColor.value,
|
|
timestamp: Date.now()
|
|
}
|
|
historyList.value.unshift(historyItem)
|
|
currentHistoryIndex.value = 0
|
|
} else if (currentHistoryIndex.value >= 0) {
|
|
historyList.value[currentHistoryIndex.value].resultImage = dataUrl
|
|
}
|
|
|
|
processing.value = false
|
|
|
|
// Auto-close mobile color picker on mobile
|
|
if (window.innerWidth <= 768) {
|
|
closeMobileColorPicker()
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Apply background image error:', error)
|
|
message.error(t('Failed to apply background image'))
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
// Handle custom background upload
|
|
const handleCustomBackgroundUpload = async (options: any) => {
|
|
const { file } = options
|
|
|
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
|
if (!validTypes.includes(file.file?.type || '')) {
|
|
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
|
return
|
|
}
|
|
|
|
const maxSize = 10 * 1024 * 1024
|
|
if ((file.file?.size || 0) > maxSize) {
|
|
message.warning(t('Image size exceeds 10MB limit'))
|
|
return
|
|
}
|
|
|
|
try {
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
const dataUrl = e.target?.result as string
|
|
if (dataUrl) {
|
|
// Add to custom backgrounds
|
|
customBackgrounds.value.unshift(dataUrl)
|
|
// Limit to 20 custom backgrounds
|
|
if (customBackgrounds.value.length > 20) {
|
|
customBackgrounds.value = customBackgrounds.value.slice(0, 20)
|
|
}
|
|
saveCustomBackgrounds()
|
|
message.success(t('Background uploaded successfully'))
|
|
}
|
|
}
|
|
reader.readAsDataURL(file.file)
|
|
} catch (error) {
|
|
message.error(t('Failed to upload background'))
|
|
}
|
|
}
|
|
|
|
// Remove custom background
|
|
const removeCustomBackground = (index: number) => {
|
|
customBackgrounds.value.splice(index, 1)
|
|
saveCustomBackgrounds()
|
|
}
|
|
|
|
// 36 common background colors - light to dark gradient
|
|
const commonColors = ref([
|
|
// Pure Whites (3)
|
|
'#FFFFFF', // Pure White
|
|
'#FEFEFE', // Almost White
|
|
'#FAFAFA', // Off White
|
|
|
|
// Light Grays (6)
|
|
'#F5F5F5', // Very Light Gray 1
|
|
'#F0F0F0', // Very Light Gray 2
|
|
'#EBEBEB', // Very Light Gray 3
|
|
'#E8E8E8', // Light Gray 1
|
|
'#E0E0E0', // Light Gray 2
|
|
'#DADADA', // Light Gray 3
|
|
|
|
// Light Pinks & Peaches (6)
|
|
'#FFE4E1', // Misty Rose
|
|
'#FFDDE1', // Light Pink
|
|
'#FFE4CC', // Light Peach 1
|
|
'#FFD7BE', // Light Peach 2
|
|
'#FFDAB9', // Peach Puff
|
|
'#FFCBA4', // Deep Peach
|
|
|
|
// Light Yellows & Creams (6)
|
|
'#FFFACD', // Lemon Chiffon
|
|
'#FFF4CC', // Light Yellow 1
|
|
'#FFECB3', // Light Yellow 2
|
|
'#FFE4B5', // Moccasin
|
|
'#FFDEAD', // Navajo White
|
|
'#FFD699', // Light Orange
|
|
|
|
// Light Greens & Blues (6)
|
|
'#E8F5E9', // Light Green 1
|
|
'#D4EDDA', // Light Green 2
|
|
'#D4E4BC', // Pale Green
|
|
'#E0F7FA', // Light Cyan
|
|
'#C8E6F5', // Light Sky Blue
|
|
'#B3E5FC', // Light Blue
|
|
|
|
// Light Purples & Lavenders (3)
|
|
'#F3E5F5', // Lavender Blush
|
|
'#EDE7F6', // Light Purple 1
|
|
'#E6D7FF', // Light Purple 2
|
|
|
|
// Medium to Dark Grays (6)
|
|
'#D4D4D4', // Medium Gray 1
|
|
'#CACACA', // Medium Gray 2
|
|
'#BEBEBE', // Medium Gray 3
|
|
'#B0B0B0', // Medium Gray 4
|
|
'#A0A0A0', // Dark Gray 1
|
|
'#909090' // Dark Gray 2
|
|
])
|
|
|
|
// OKLCH color utilities - perceptually uniform color space
|
|
const hexToOklch = (hex: string): { l: number; c: number; h: number } => {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
|
if (!result) return { l: 1, c: 0, h: 0 }
|
|
|
|
// Convert hex to linear RGB
|
|
let r = parseInt(result[1], 16) / 255
|
|
let g = parseInt(result[2], 16) / 255
|
|
let b = parseInt(result[3], 16) / 255
|
|
|
|
// Apply sRGB to linear RGB conversion
|
|
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
|
|
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
|
|
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
|
|
|
|
// Convert linear RGB to OKLab
|
|
const l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
|
const m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
|
const s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
|
|
|
const l = Math.cbrt(l_)
|
|
const m = Math.cbrt(m_)
|
|
const s = Math.cbrt(s_)
|
|
|
|
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
|
const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
|
const bVal = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
|
|
|
// Convert OKLab to OKLCH
|
|
const C = Math.sqrt(a * a + bVal * bVal)
|
|
const H = Math.atan2(bVal, a) * 180 / Math.PI
|
|
|
|
return { l: L, c: C, h: H >= 0 ? H : H + 360 }
|
|
}
|
|
|
|
const oklchToHex = (l: number, c: number, h: number): string => {
|
|
// Convert OKLCH to OKLab
|
|
const hRad = h * Math.PI / 180
|
|
const a = c * Math.cos(hRad)
|
|
const bVal = c * Math.sin(hRad)
|
|
|
|
// Convert OKLab to linear RGB
|
|
const l_ = l + 0.3963377774 * a + 0.2158037573 * bVal
|
|
const m_ = l - 0.1055613458 * a - 0.0638541728 * bVal
|
|
const s_ = l - 0.0894841775 * a - 1.2914855480 * bVal
|
|
|
|
const lCube = l_ * l_ * l_
|
|
const mCube = m_ * m_ * m_
|
|
const sCube = s_ * s_ * s_
|
|
|
|
let r = +4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube
|
|
let g = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube
|
|
let bRgb = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.7076147010 * sCube
|
|
|
|
// Apply linear RGB to sRGB conversion
|
|
r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r
|
|
g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g
|
|
bRgb = bRgb > 0.0031308 ? 1.055 * Math.pow(bRgb, 1 / 2.4) - 0.055 : 12.92 * bRgb
|
|
|
|
// Clamp values to [0, 1]
|
|
r = Math.max(0, Math.min(1, r))
|
|
g = Math.max(0, Math.min(1, g))
|
|
bRgb = Math.max(0, Math.min(1, bRgb))
|
|
|
|
// Convert to hex
|
|
const rHex = Math.round(r * 255).toString(16).padStart(2, '0')
|
|
const gHex = Math.round(g * 255).toString(16).padStart(2, '0')
|
|
const bHex = Math.round(bRgb * 255).toString(16).padStart(2, '0')
|
|
|
|
return `#${rHex}${gHex}${bHex}`.toUpperCase()
|
|
}
|
|
|
|
// Generate 11 color shades with current color in the middle (6th position) using OKLCH
|
|
const generateColorShades = (baseColor: string): string[] => {
|
|
const { l, c, h } = hexToOklch(baseColor)
|
|
const shades: string[] = []
|
|
|
|
// Generate 5 lighter shades before current color
|
|
// Use perceptually uniform lightness steps (±0.08 intervals)
|
|
for (let i = 5; i > 0; i--) {
|
|
const lighterL = Math.min(1, l + (i * 0.08))
|
|
shades.push(oklchToHex(lighterL, c, h))
|
|
}
|
|
|
|
// Add current color in the middle (6th position)
|
|
shades.push(baseColor)
|
|
|
|
// Generate 5 darker shades after current color
|
|
for (let i = 1; i <= 5; i++) {
|
|
const darkerL = Math.max(0, l - (i * 0.08))
|
|
shades.push(oklchToHex(darkerL, c, h))
|
|
}
|
|
|
|
return shades
|
|
}
|
|
const resultImageUrl = computed(() => {
|
|
if (!resultImage.value) return ''
|
|
return resultImage.value
|
|
})
|
|
|
|
const historyList = ref<HistoryItem[]>([])
|
|
const currentHistoryIndex = ref<number>(-1)
|
|
const isDragging = ref(false)
|
|
const dragCounter = ref(0)
|
|
const processing = ref(false)
|
|
const showMobileColorPicker = ref(false)
|
|
let fabricCanvas: Canvas | null = null
|
|
|
|
// Tabs state
|
|
const activeTab = ref<'color' | 'image'>('color')
|
|
|
|
// Pexels images state
|
|
const pexelsImages = ref<any[]>([])
|
|
const pexelsLoading = ref(false)
|
|
const pexelsPage = ref(1)
|
|
const pexelsQuery = ref('nature background')
|
|
const pexelsSearchInput = ref('nature background')
|
|
const PEXELS_API_KEY = __PEXELS_API_KEY__ || ''
|
|
const pexelsDisplayCount = ref(12)
|
|
const PEXELS_PER_PAGE = 80 // Fetch 80 images per API call
|
|
const PEXELS_DISPLAY_INCREMENT = 12 // Display 12 more images each time
|
|
const pexelsHasMore = ref(true) // Track if there are more images to fetch
|
|
|
|
// Custom background images
|
|
const customBackgrounds = ref<string[]>([])
|
|
|
|
// Computed: Display limited Pexels images
|
|
const displayedPexelsImages = computed(() => {
|
|
return pexelsImages.value.slice(0, pexelsDisplayCount.value)
|
|
})
|
|
|
|
// Show "Load More" button condition
|
|
const showLoadMoreButton = computed(() => {
|
|
// Show button if:
|
|
// 1. There are more images in cache to display, OR
|
|
// 2. There might be more images to fetch from API
|
|
return pexelsImages.value.length > pexelsDisplayCount.value ||
|
|
(pexelsImages.value.length > 0 && pexelsHasMore.value && pexelsImages.value.length >= pexelsDisplayCount.value)
|
|
})
|
|
|
|
// Load more Pexels images
|
|
const loadMorePexelsImages = async () => {
|
|
// First, try to display more from cached images
|
|
const remainingCached = pexelsImages.value.length - pexelsDisplayCount.value
|
|
|
|
if (remainingCached >= PEXELS_DISPLAY_INCREMENT) {
|
|
// Enough cached images, just increase display count
|
|
pexelsDisplayCount.value += PEXELS_DISPLAY_INCREMENT
|
|
} else if (remainingCached > 0) {
|
|
// Some cached images left, display them first
|
|
pexelsDisplayCount.value = pexelsImages.value.length
|
|
|
|
// Then fetch more if available
|
|
if (pexelsHasMore.value) {
|
|
pexelsPage.value++
|
|
await fetchPexelsImages()
|
|
// Display up to 12 more from the newly fetched images
|
|
const needed = PEXELS_DISPLAY_INCREMENT - remainingCached
|
|
pexelsDisplayCount.value = Math.min(
|
|
pexelsImages.value.length,
|
|
pexelsDisplayCount.value + needed
|
|
)
|
|
}
|
|
} else {
|
|
// No cached images left, fetch more from API
|
|
if (pexelsHasMore.value) {
|
|
pexelsPage.value++
|
|
await fetchPexelsImages()
|
|
// Display 12 more images
|
|
pexelsDisplayCount.value = Math.min(
|
|
pexelsImages.value.length,
|
|
pexelsDisplayCount.value + PEXELS_DISPLAY_INCREMENT
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search Pexels images with new query
|
|
const searchPexelsImages = async () => {
|
|
const searchTerm = pexelsSearchInput.value.trim()
|
|
if (!searchTerm) {
|
|
message.warning(t('Please enter search keywords'))
|
|
return
|
|
}
|
|
|
|
// Reset state for new search
|
|
pexelsQuery.value = searchTerm
|
|
pexelsPage.value = 1
|
|
pexelsImages.value = []
|
|
pexelsDisplayCount.value = 12
|
|
pexelsHasMore.value = true
|
|
|
|
// Fetch new results
|
|
await fetchPexelsImages()
|
|
}
|
|
|
|
// Load custom backgrounds from localStorage
|
|
const loadCustomBackgrounds = () => {
|
|
try {
|
|
const saved = localStorage.getItem('add_background_custom_images')
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved)
|
|
if (Array.isArray(parsed)) {
|
|
customBackgrounds.value = parsed
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load custom backgrounds:', error)
|
|
}
|
|
}
|
|
|
|
// Save custom backgrounds to localStorage
|
|
const saveCustomBackgrounds = () => {
|
|
try {
|
|
localStorage.setItem('add_background_custom_images', JSON.stringify(customBackgrounds.value))
|
|
} catch (error) {
|
|
console.error('Failed to save custom backgrounds:', error)
|
|
}
|
|
}
|
|
|
|
const adjustCanvasSize = () => {
|
|
const canvas = canvasRef.value
|
|
if (!canvas || !fabricCanvas) return
|
|
|
|
const container = canvas.parentElement
|
|
if (!container) return
|
|
|
|
const previewSection = container.closest('.preview-section') as HTMLElement
|
|
if (!previewSection) return
|
|
|
|
// Check if mobile
|
|
const isMobile = window.innerWidth <= 768
|
|
|
|
const previewRect = previewSection.getBoundingClientRect()
|
|
const padding = isMobile ? 24 : 48
|
|
const maxAvailableWidth = Math.max(0, previewRect.width - padding)
|
|
const maxAvailableHeight = Math.max(0, previewRect.height - padding)
|
|
|
|
if (maxAvailableWidth <= 0 || maxAvailableHeight <= 0) return
|
|
|
|
// Get canvas natural dimensions
|
|
const canvasWidth = fabricCanvas.width || 1
|
|
const canvasHeight = fabricCanvas.height || 1
|
|
|
|
// Calculate scale to fit within available space
|
|
const scale = Math.min(
|
|
maxAvailableWidth / canvasWidth,
|
|
maxAvailableHeight / canvasHeight,
|
|
1
|
|
)
|
|
|
|
// Apply display dimensions to container
|
|
const displayWidth = canvasWidth * scale
|
|
const displayHeight = canvasHeight * scale
|
|
|
|
container.style.width = `${displayWidth}px`
|
|
container.style.height = `${displayHeight}px`
|
|
|
|
// Scale canvas visually using CSS transform
|
|
canvas.style.transform = `scale(${scale})`
|
|
canvas.style.transformOrigin = 'top left'
|
|
}
|
|
|
|
watch(uploadedImageUrl, adjustCanvasSize)
|
|
|
|
const handleResize = () => {
|
|
adjustCanvasSize()
|
|
}
|
|
|
|
const handlePaste = async (event: ClipboardEvent) => {
|
|
// Prevent paste handling when user is in right sidebar or mobile sheet
|
|
const target = event.target as HTMLElement
|
|
|
|
// Check if paste is triggered from right sidebar or mobile sheet
|
|
if (target) {
|
|
const isInRightSidebar = target.closest('.right-sidebar')
|
|
const isInMobileSheet = target.closest('.mobile-color-sheet')
|
|
const isInUploadDragger = target.closest('.n-upload-dragger')
|
|
|
|
// Don't handle paste if it's from sidebar, mobile sheet, or upload dragger
|
|
if (isInRightSidebar || isInMobileSheet || isInUploadDragger) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Only handle paste when no image is uploaded yet (in upload area)
|
|
if (uploadedImage.value) {
|
|
return
|
|
}
|
|
|
|
const items = event.clipboardData?.items
|
|
if (!items) return
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i]
|
|
if (item.type.indexOf('image') !== -1) {
|
|
event.preventDefault()
|
|
const file = item.getAsFile()
|
|
if (file) {
|
|
processFile(file)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
const text = event.clipboardData?.getData('text')
|
|
if (text && isValidImageUrl(text)) {
|
|
event.preventDefault()
|
|
imageUrl.value = text.trim()
|
|
await handleUrlSubmit()
|
|
}
|
|
}
|
|
|
|
const isValidImageUrl = (url: string): boolean => {
|
|
if (!url || typeof url !== 'string') return false
|
|
try {
|
|
const urlObj = new URL(url)
|
|
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
|
return false
|
|
}
|
|
|
|
const pathname = urlObj.pathname.toLowerCase()
|
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']
|
|
|
|
const hasImageExtension = imageExtensions.some(ext => pathname.endsWith(ext)) ||
|
|
urlObj.pathname.match(/\.(jpg|jpeg|png|webp|gif|bmp)(\?|$)/i) !== null
|
|
|
|
return hasImageExtension || pathname.length > 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const handleUrlPaste = async (event: ClipboardEvent) => {
|
|
const text = event.clipboardData?.getData('text')
|
|
if (text && isValidImageUrl(text)) {
|
|
event.preventDefault()
|
|
imageUrl.value = text.trim()
|
|
await handleUrlSubmit()
|
|
}
|
|
}
|
|
|
|
const handleUrlSubmit = async () => {
|
|
const url = imageUrl.value.trim()
|
|
if (!url) {
|
|
message.warning(t('Please enter an image URL'))
|
|
return
|
|
}
|
|
|
|
if (!isValidImageUrl(url)) {
|
|
message.warning(t('Please enter a valid image URL'))
|
|
return
|
|
}
|
|
|
|
processing.value = true
|
|
resultImage.value = ''
|
|
currentHistoryIndex.value = -1
|
|
|
|
try {
|
|
const response = await fetch(url, { mode: 'cors' })
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load image: ${response.status}`)
|
|
}
|
|
|
|
const blob = await response.blob()
|
|
|
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
|
if (!validTypes.includes(blob.type)) {
|
|
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
const maxSize = 10 * 1024 * 1024
|
|
if (blob.size > maxSize) {
|
|
message.warning(t('Image size exceeds 10MB limit'))
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
const originalFile = new File([blob], 'image-from-url', { type: blob.type })
|
|
|
|
try {
|
|
const compressedFile = await compressImageFile(originalFile, {
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
quality: 0.92,
|
|
mode: 'contain'
|
|
})
|
|
|
|
uploadedImage.value = compressedFile
|
|
const imageUrl = URL.createObjectURL(compressedFile)
|
|
uploadedImageUrl.value = imageUrl
|
|
|
|
await extractDominantColorAndInitialize(imageUrl)
|
|
} catch (error) {
|
|
message.error(t('Image processing failed, please try again'))
|
|
processing.value = false
|
|
}
|
|
} catch (error: any) {
|
|
let errorMessage = t('Failed to load image from URL')
|
|
|
|
if (error.message?.includes('CORS')) {
|
|
errorMessage = t('CORS error. The image server does not allow cross-origin access.')
|
|
} else if (error.message?.includes('Failed to load')) {
|
|
errorMessage = t('Failed to load image. Please check the URL and try again.')
|
|
}
|
|
|
|
message.error(errorMessage)
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', handleResize)
|
|
window.addEventListener('paste', handlePaste)
|
|
loadFavoriteColors()
|
|
loadCustomBackgrounds()
|
|
fetchPexelsImages()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize)
|
|
window.removeEventListener('paste', handlePaste)
|
|
if (colorChangeDebounceTimer) {
|
|
clearTimeout(colorChangeDebounceTimer)
|
|
}
|
|
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
|
URL.revokeObjectURL(uploadedImageUrl.value)
|
|
}
|
|
if (resultImageBlobUrl.value) {
|
|
URL.revokeObjectURL(resultImageBlobUrl.value)
|
|
}
|
|
if (fabricCanvas) {
|
|
fabricCanvas.dispose()
|
|
fabricCanvas = null
|
|
}
|
|
// Restore body scroll
|
|
document.body.style.overflow = ''
|
|
})
|
|
|
|
const triggerFileInput = () => {
|
|
if (fileInputRef.value) {
|
|
fileInputRef.value.value = ''
|
|
fileInputRef.value.click()
|
|
}
|
|
}
|
|
|
|
const handleFileSelect = (event: Event) => {
|
|
const target = event.target as HTMLInputElement
|
|
const file = target.files?.[0]
|
|
if (file) {
|
|
processFile(file)
|
|
}
|
|
}
|
|
|
|
const hasFiles = (event: DragEvent) => {
|
|
const types = event.dataTransfer?.types
|
|
if (!types) return false
|
|
return Array.from(types).includes('Files')
|
|
}
|
|
|
|
const handleDragEnter = (event: DragEvent) => {
|
|
if (!hasFiles(event)) return
|
|
dragCounter.value += 1
|
|
isDragging.value = true
|
|
}
|
|
|
|
const handleDragOver = (event: DragEvent) => {
|
|
if (!hasFiles(event)) return
|
|
event.dataTransfer!.dropEffect = 'copy'
|
|
isDragging.value = true
|
|
}
|
|
|
|
const handleDragLeave = (event: DragEvent) => {
|
|
if (!hasFiles(event)) return
|
|
dragCounter.value = Math.max(0, dragCounter.value - 1)
|
|
if (dragCounter.value === 0) {
|
|
isDragging.value = false
|
|
}
|
|
}
|
|
|
|
const handleDrop = (event: DragEvent) => {
|
|
if (!hasFiles(event)) return
|
|
event.preventDefault()
|
|
dragCounter.value = 0
|
|
isDragging.value = false
|
|
const file = event.dataTransfer?.files[0]
|
|
if (file && file.type.startsWith('image/')) {
|
|
processFile(file)
|
|
} else {
|
|
message.warning(t('Please upload an image file'))
|
|
}
|
|
}
|
|
|
|
const processFile = async (file: File) => {
|
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
|
if (!validTypes.includes(file.type)) {
|
|
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
|
return
|
|
}
|
|
|
|
const maxSize = 10 * 1024 * 1024
|
|
if (file.size > maxSize) {
|
|
message.warning(t('Image size exceeds 10MB limit'))
|
|
return
|
|
}
|
|
|
|
try {
|
|
const compressedFile = await compressImageFile(file, {
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
quality: 0.92,
|
|
mode: 'contain'
|
|
})
|
|
|
|
// Check if image has transparency
|
|
const hasTransparency = await checkImageTransparency(compressedFile)
|
|
|
|
if (!hasTransparency) {
|
|
// No transparency detected, auto remove background silently
|
|
processing.value = true
|
|
|
|
try {
|
|
const removedBgImage = await removeBackground(compressedFile)
|
|
if (removedBgImage) {
|
|
uploadedImage.value = removedBgImage
|
|
resultImage.value = ''
|
|
currentHistoryIndex.value = -1
|
|
|
|
const reader = new FileReader()
|
|
reader.onload = async (e) => {
|
|
uploadedImageUrl.value = e.target?.result as string
|
|
await extractDominantColorAndInitialize(uploadedImageUrl.value)
|
|
}
|
|
reader.readAsDataURL(removedBgImage)
|
|
return
|
|
}
|
|
} catch (error) {
|
|
console.error('Auto remove background failed:', error)
|
|
// Fall through to use original image silently
|
|
}
|
|
}
|
|
|
|
// Use image directly (already has transparency or removal failed)
|
|
uploadedImage.value = compressedFile
|
|
resultImage.value = ''
|
|
currentHistoryIndex.value = -1
|
|
|
|
const reader = new FileReader()
|
|
reader.onload = async (e) => {
|
|
uploadedImageUrl.value = e.target?.result as string
|
|
await extractDominantColorAndInitialize(uploadedImageUrl.value)
|
|
}
|
|
reader.readAsDataURL(compressedFile)
|
|
} catch (error) {
|
|
message.error(t('Image processing failed, please try again'))
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
// Check if image has transparent pixels
|
|
const checkImageTransparency = (file: File): Promise<boolean> => {
|
|
return new Promise((resolve) => {
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = img.width
|
|
canvas.height = img.height
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
if (!ctx) {
|
|
resolve(false)
|
|
return
|
|
}
|
|
|
|
ctx.drawImage(img, 0, 0)
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
|
const pixels = imageData.data
|
|
|
|
// Sample pixels to check for transparency
|
|
// Check every 10th pixel for performance
|
|
let transparentPixels = 0
|
|
const sampleRate = 10
|
|
|
|
for (let i = 3; i < pixels.length; i += 4 * sampleRate) {
|
|
if (pixels[i] < 255) {
|
|
transparentPixels++
|
|
}
|
|
}
|
|
|
|
// If more than 1% pixels are transparent, consider it has transparency
|
|
const totalSampledPixels = Math.floor(pixels.length / (4 * sampleRate))
|
|
const transparencyRatio = transparentPixels / totalSampledPixels
|
|
|
|
resolve(transparencyRatio > 0.01)
|
|
}
|
|
img.onerror = () => resolve(false)
|
|
img.src = e.target?.result as string
|
|
}
|
|
reader.onerror = () => resolve(false)
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|
|
|
|
// Remove background using API
|
|
const removeBackground = async (file: File): Promise<File | null> => {
|
|
if (!authStore.isLoggedIn) {
|
|
message.warning(t('Please login first to use auto remove background feature'))
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 180000)
|
|
|
|
const response = await fetch('/api/v1/tools/rmbg/file/free', {
|
|
method: 'POST',
|
|
body: formData,
|
|
signal: controller.signal
|
|
})
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`)
|
|
}
|
|
|
|
const reader = response.body?.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
if (!reader) {
|
|
throw new Error('Response body is not readable')
|
|
}
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const result = JSON.parse(line.trim())
|
|
if (result.status === 'success' && result.image_url) {
|
|
// Download the image and convert to File
|
|
const imgResponse = await fetch(result.image_url)
|
|
const blob = await imgResponse.blob()
|
|
const removedFile = new File([blob], 'removed-bg.png', { type: 'image/png' })
|
|
return removedFile
|
|
}
|
|
} catch (parseError) {
|
|
// Continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process last line
|
|
if (buffer.trim()) {
|
|
try {
|
|
const result = JSON.parse(buffer.trim())
|
|
if (result.status === 'success' && result.image_url) {
|
|
const imgResponse = await fetch(result.image_url)
|
|
const blob = await imgResponse.blob()
|
|
const removedFile = new File([blob], 'removed-bg.png', { type: 'image/png' })
|
|
return removedFile
|
|
}
|
|
} catch (parseError) {
|
|
// Continue
|
|
}
|
|
}
|
|
|
|
return null
|
|
} catch (error: any) {
|
|
console.error('Remove background error:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const initializeCanvas = async () => {
|
|
if (!canvasRef.value || !uploadedImageUrl.value) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
await nextTick()
|
|
|
|
// Clean up old canvas
|
|
if (fabricCanvas) {
|
|
fabricCanvas.dispose()
|
|
fabricCanvas = null
|
|
}
|
|
|
|
// Create new fabric canvas
|
|
fabricCanvas = new Canvas(canvasRef.value, {
|
|
selection: false
|
|
})
|
|
|
|
// Load image using fabric v7 API
|
|
const img = await FabricImage.fromURL(uploadedImageUrl.value, {
|
|
crossOrigin: 'anonymous'
|
|
})
|
|
|
|
if (!img || !fabricCanvas) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
// Get image natural dimensions (already compressed to max 1024x1024)
|
|
const imgWidth = img.width || 1
|
|
const imgHeight = img.height || 1
|
|
|
|
// Set canvas to actual image dimensions (no scaling)
|
|
// This ensures download maintains the same size as upload
|
|
fabricCanvas.setDimensions({ width: imgWidth, height: imgHeight })
|
|
|
|
img.set({
|
|
left: 0,
|
|
top: 0,
|
|
selectable: false,
|
|
evented: false,
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
})
|
|
|
|
fabricCanvas.add(img)
|
|
fabricCanvas.centerObject(img)
|
|
fabricCanvas.renderAll()
|
|
|
|
// Adjust visual display size to fit container
|
|
adjustCanvasSize()
|
|
|
|
// Auto-apply default background color
|
|
await nextTick()
|
|
applyBackgroundSilent()
|
|
|
|
processing.value = false
|
|
} catch (error) {
|
|
console.error('Canvas initialization error:', error)
|
|
message.error(t('Failed to load image'))
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
const validateHexColor = () => {
|
|
const hex = backgroundColor.value.trim()
|
|
const isValid = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hex)
|
|
isColorValid.value = isValid
|
|
return isValid
|
|
}
|
|
|
|
// Auto-apply background when color changes
|
|
const onColorChange = () => {
|
|
if (validateHexColor()) {
|
|
debouncedApplyBackground()
|
|
}
|
|
}
|
|
|
|
const onHexInputChange = () => {
|
|
// Validate and auto-format hex input
|
|
let hex = backgroundColor.value.trim()
|
|
if (!hex.startsWith('#')) {
|
|
hex = '#' + hex
|
|
backgroundColor.value = hex
|
|
}
|
|
if (validateHexColor()) {
|
|
debouncedApplyBackground()
|
|
}
|
|
}
|
|
|
|
const onHexInputBlur = () => {
|
|
// Format hex on blur
|
|
let hex = backgroundColor.value.trim().toUpperCase()
|
|
if (!hex.startsWith('#')) {
|
|
hex = '#' + hex
|
|
}
|
|
// Expand 3-digit to 6-digit
|
|
if (/^#[A-Fa-f0-9]{3}$/.test(hex)) {
|
|
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
|
}
|
|
backgroundColor.value = hex
|
|
if (validateHexColor()) {
|
|
applyBackgroundSilent()
|
|
}
|
|
}
|
|
|
|
const selectShade = (color: string) => {
|
|
backgroundColor.value = color
|
|
applyBackgroundSilent()
|
|
// Auto-close mobile color picker on mobile
|
|
if (window.innerWidth <= 768) {
|
|
closeMobileColorPicker()
|
|
}
|
|
}
|
|
|
|
const selectCommonColor = (color: string) => {
|
|
backgroundColor.value = color
|
|
applyBackgroundSilent()
|
|
// Auto-close mobile color picker on mobile
|
|
if (window.innerWidth <= 768) {
|
|
closeMobileColorPicker()
|
|
}
|
|
}
|
|
|
|
const toggleMobileColorPicker = () => {
|
|
showMobileColorPicker.value = !showMobileColorPicker.value
|
|
// Prevent body scroll when sheet is open
|
|
if (showMobileColorPicker.value) {
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
}
|
|
|
|
const closeMobileColorPicker = () => {
|
|
showMobileColorPicker.value = false
|
|
document.body.style.overflow = ''
|
|
}
|
|
|
|
const debouncedApplyBackground = () => {
|
|
if (colorChangeDebounceTimer) {
|
|
clearTimeout(colorChangeDebounceTimer)
|
|
}
|
|
colorChangeDebounceTimer = setTimeout(() => {
|
|
applyBackgroundSilent()
|
|
}, 150)
|
|
}
|
|
|
|
const applyBackgroundSilent = async () => {
|
|
if (!fabricCanvas || !uploadedImage.value) return
|
|
if (!validateHexColor()) return
|
|
|
|
try {
|
|
const objects = fabricCanvas.getObjects()
|
|
if (objects.length === 0) return
|
|
|
|
// Set background color using fabric v7 API
|
|
fabricCanvas.backgroundColor = backgroundColor.value
|
|
fabricCanvas.renderAll()
|
|
|
|
// Export to data URL using fabric v7 API
|
|
const dataUrl = fabricCanvas.toDataURL({
|
|
format: 'png',
|
|
quality: 0.95,
|
|
multiplier: 1
|
|
})
|
|
|
|
resultImage.value = dataUrl
|
|
await cacheResultImage(dataUrl)
|
|
|
|
// Update history
|
|
if (currentHistoryIndex.value === -1 && uploadedImage.value && uploadedImageUrl.value) {
|
|
const historyItem: HistoryItem = {
|
|
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
originalImageUrl: uploadedImageUrl.value,
|
|
originalImageFile: uploadedImage.value,
|
|
resultImage: dataUrl,
|
|
backgroundColor: backgroundColor.value,
|
|
timestamp: Date.now()
|
|
}
|
|
historyList.value.unshift(historyItem)
|
|
currentHistoryIndex.value = 0
|
|
} else if (currentHistoryIndex.value >= 0) {
|
|
historyList.value[currentHistoryIndex.value].resultImage = dataUrl
|
|
historyList.value[currentHistoryIndex.value].backgroundColor = backgroundColor.value
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Apply background error:', error)
|
|
}
|
|
}
|
|
|
|
const resetUpload = () => {
|
|
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
|
URL.revokeObjectURL(uploadedImageUrl.value)
|
|
}
|
|
if (resultImageBlobUrl.value) {
|
|
URL.revokeObjectURL(resultImageBlobUrl.value)
|
|
resultImageBlobUrl.value = ''
|
|
}
|
|
if (fabricCanvas) {
|
|
fabricCanvas.dispose()
|
|
fabricCanvas = null
|
|
}
|
|
uploadedImage.value = null
|
|
uploadedImageUrl.value = ''
|
|
resultImage.value = ''
|
|
imageUrl.value = ''
|
|
backgroundColor.value = '#FFFFFF'
|
|
currentHistoryIndex.value = -1
|
|
if (fileInputRef.value) {
|
|
fileInputRef.value.value = ''
|
|
}
|
|
}
|
|
|
|
// Clear background (restore to transparent)
|
|
const clearBackground = async () => {
|
|
if (!fabricCanvas || !uploadedImage.value || !uploadedImageUrl.value) return
|
|
|
|
try {
|
|
processing.value = true
|
|
|
|
// Clean up old canvas
|
|
if (fabricCanvas) {
|
|
fabricCanvas.dispose()
|
|
fabricCanvas = null
|
|
}
|
|
|
|
// Create new fabric canvas WITHOUT background
|
|
fabricCanvas = new Canvas(canvasRef.value, {
|
|
selection: false,
|
|
backgroundColor: null
|
|
})
|
|
|
|
// Load the original uploaded image (already has transparency)
|
|
const img = await FabricImage.fromURL(uploadedImageUrl.value, {
|
|
crossOrigin: 'anonymous'
|
|
})
|
|
|
|
if (!img || !fabricCanvas) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
// Get image natural dimensions
|
|
const imgWidth = img.width || 1
|
|
const imgHeight = img.height || 1
|
|
|
|
// Set canvas to actual image dimensions
|
|
fabricCanvas.setDimensions({ width: imgWidth, height: imgHeight })
|
|
|
|
img.set({
|
|
left: 0,
|
|
top: 0,
|
|
selectable: false,
|
|
evented: false,
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
})
|
|
|
|
// Ensure no background color (null for transparent)
|
|
fabricCanvas.backgroundColor = null
|
|
|
|
fabricCanvas.add(img)
|
|
fabricCanvas.centerObject(img)
|
|
fabricCanvas.renderAll()
|
|
|
|
// Adjust visual display size to fit container
|
|
adjustCanvasSize()
|
|
|
|
// Export result with transparent background
|
|
const dataUrl = fabricCanvas.toDataURL({
|
|
format: 'png',
|
|
quality: 0.95,
|
|
multiplier: 1
|
|
})
|
|
|
|
resultImage.value = dataUrl
|
|
await cacheResultImage(dataUrl)
|
|
|
|
// Clear hex input by setting empty string (placeholder will show)
|
|
backgroundColor.value = ''
|
|
|
|
// Update or create history item
|
|
if (currentHistoryIndex.value === -1) {
|
|
const historyItem: HistoryItem = {
|
|
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
originalImageUrl: uploadedImageUrl.value,
|
|
originalImageFile: uploadedImage.value,
|
|
resultImage: dataUrl,
|
|
backgroundColor: '',
|
|
timestamp: Date.now()
|
|
}
|
|
historyList.value.unshift(historyItem)
|
|
currentHistoryIndex.value = 0
|
|
} else if (currentHistoryIndex.value >= 0) {
|
|
historyList.value[currentHistoryIndex.value].resultImage = dataUrl
|
|
historyList.value[currentHistoryIndex.value].backgroundColor = ''
|
|
}
|
|
|
|
processing.value = false
|
|
} catch (error: any) {
|
|
console.error('Clear background error:', error)
|
|
message.error(t('Failed to clear background'))
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
// Convert RGB to light pastel color using OKLCH (adaptive approach)
|
|
const rgbToLightPastel = (r: number, g: number, b: number): string => {
|
|
// Convert RGB to hex first
|
|
const rHex = Math.round(r).toString(16).padStart(2, '0')
|
|
const gHex = Math.round(g).toString(16).padStart(2, '0')
|
|
const bHex = Math.round(b).toString(16).padStart(2, '0')
|
|
const hexColor = `#${rHex}${gHex}${bHex}`
|
|
|
|
// Convert to OKLCH
|
|
const { l, c, h } = hexToOklch(hexColor)
|
|
|
|
// Adaptive lightness: ensure it's in the light range (0.85-0.95)
|
|
// If original is already light, keep it; if dark, lighten it
|
|
let pastelL: number
|
|
if (l >= 0.85) {
|
|
// Already light enough, use original
|
|
pastelL = l
|
|
} else if (l >= 0.7) {
|
|
// Medium brightness, lighten moderately
|
|
pastelL = 0.88
|
|
} else {
|
|
// Dark color, lighten significantly
|
|
pastelL = 0.92
|
|
}
|
|
|
|
// Adaptive chroma: maintain color character while keeping it soft
|
|
// Reduce chroma but keep some saturation for color recognition
|
|
const pastelC = Math.min(c * 0.5, 0.12) // Keep up to 50% chroma, max 0.12
|
|
|
|
return oklchToHex(pastelL, pastelC, h)
|
|
}
|
|
|
|
// Extract dominant color from image using color-thief
|
|
const extractDominantColorAndInitialize = async (imageDataUrl: string) => {
|
|
try {
|
|
const img = new Image()
|
|
img.crossOrigin = 'Anonymous'
|
|
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve
|
|
img.onerror = reject
|
|
img.src = imageDataUrl
|
|
})
|
|
|
|
// Extract dominant color using color-thief
|
|
const dominantColor = colorThief.getColor(img)
|
|
|
|
if (dominantColor && Array.isArray(dominantColor) && dominantColor.length === 3) {
|
|
// Convert to light pastel color with same hue
|
|
const [r, g, b] = dominantColor
|
|
backgroundColor.value = rgbToLightPastel(r, g, b)
|
|
}
|
|
|
|
await initializeCanvas()
|
|
} catch (error) {
|
|
console.error('Failed to extract dominant color:', error)
|
|
// Fallback to default white background
|
|
backgroundColor.value = '#FFFFFF'
|
|
await initializeCanvas()
|
|
}
|
|
}
|
|
|
|
const selectHistoryItem = async (index: number) => {
|
|
if (index < 0 || index >= historyList.value.length) return
|
|
|
|
const item = historyList.value[index]
|
|
currentHistoryIndex.value = index
|
|
uploadedImageUrl.value = item.originalImageUrl
|
|
resultImage.value = item.resultImage
|
|
backgroundColor.value = item.backgroundColor
|
|
uploadedImage.value = item.originalImageFile
|
|
|
|
if (item.resultImage) {
|
|
await cacheResultImage(item.resultImage)
|
|
}
|
|
|
|
// Re-render canvas with the saved result
|
|
await initializeCanvasFromResult(item)
|
|
}
|
|
|
|
const initializeCanvasWithBackground = async (bgColor: string) => {
|
|
if (!canvasRef.value || !uploadedImageUrl.value) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
await nextTick()
|
|
|
|
// Clean up old canvas
|
|
if (fabricCanvas) {
|
|
fabricCanvas.dispose()
|
|
fabricCanvas = null
|
|
}
|
|
|
|
// Create new fabric canvas
|
|
fabricCanvas = new Canvas(canvasRef.value, {
|
|
selection: false
|
|
})
|
|
|
|
// Load image using fabric v7 API
|
|
const img = await FabricImage.fromURL(uploadedImageUrl.value, {
|
|
crossOrigin: 'anonymous'
|
|
})
|
|
|
|
if (!img || !fabricCanvas) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
// Get image natural dimensions (already compressed to max 1024x1024)
|
|
const imgWidth = img.width || 1
|
|
const imgHeight = img.height || 1
|
|
|
|
// Set canvas to actual image dimensions (no scaling)
|
|
fabricCanvas.setDimensions({ width: imgWidth, height: imgHeight })
|
|
|
|
img.set({
|
|
left: 0,
|
|
top: 0,
|
|
selectable: false,
|
|
evented: false,
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
})
|
|
|
|
// Apply saved background color if provided
|
|
if (bgColor) {
|
|
fabricCanvas.backgroundColor = bgColor
|
|
}
|
|
|
|
fabricCanvas.add(img)
|
|
fabricCanvas.centerObject(img)
|
|
fabricCanvas.renderAll()
|
|
|
|
// Adjust visual display size to fit container
|
|
adjustCanvasSize()
|
|
|
|
processing.value = false
|
|
} catch (error) {
|
|
console.error('Canvas initialization error:', error)
|
|
message.error(t('Failed to load image'))
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
// Initialize canvas from saved result (for history items)
|
|
const initializeCanvasFromResult = async (item: HistoryItem) => {
|
|
if (!canvasRef.value || !item.resultImage) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
await nextTick()
|
|
|
|
// Clean up old canvas
|
|
if (fabricCanvas) {
|
|
fabricCanvas.dispose()
|
|
fabricCanvas = null
|
|
}
|
|
|
|
// Create new fabric canvas
|
|
fabricCanvas = new Canvas(canvasRef.value, {
|
|
selection: false
|
|
})
|
|
|
|
// Load the result image directly
|
|
const resultImg = await FabricImage.fromURL(item.resultImage, {
|
|
crossOrigin: 'anonymous'
|
|
})
|
|
|
|
if (!resultImg || !fabricCanvas) {
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
// Get image natural dimensions
|
|
const imgWidth = resultImg.width || 1
|
|
const imgHeight = resultImg.height || 1
|
|
|
|
// Set canvas to actual image dimensions
|
|
fabricCanvas.setDimensions({ width: imgWidth, height: imgHeight })
|
|
|
|
resultImg.set({
|
|
left: 0,
|
|
top: 0,
|
|
selectable: false,
|
|
evented: false,
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
})
|
|
|
|
fabricCanvas.add(resultImg)
|
|
fabricCanvas.centerObject(resultImg)
|
|
fabricCanvas.renderAll()
|
|
|
|
// Adjust visual display size to fit container
|
|
adjustCanvasSize()
|
|
|
|
processing.value = false
|
|
} catch (error) {
|
|
console.error('Canvas initialization from result error:', error)
|
|
message.error(t('Failed to load image'))
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
const getHistoryThumbnailUrl = (item: HistoryItem): string => {
|
|
if (item.resultImage) {
|
|
return item.resultImage
|
|
}
|
|
return item.originalImageUrl
|
|
}
|
|
|
|
const cacheResultImage = async (imageUrl: string) => {
|
|
try {
|
|
if (resultImageBlobUrl.value) {
|
|
URL.revokeObjectURL(resultImageBlobUrl.value)
|
|
resultImageBlobUrl.value = ''
|
|
}
|
|
|
|
const response = await fetch(imageUrl)
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch image: ${response.status}`)
|
|
}
|
|
const blob = await response.blob()
|
|
resultImageBlobUrl.value = URL.createObjectURL(blob)
|
|
} catch (error) {
|
|
// Cache failure doesn't affect display
|
|
}
|
|
}
|
|
|
|
const handleDownload = async () => {
|
|
if (!resultImage.value) return
|
|
|
|
try {
|
|
let blobUrl = resultImageBlobUrl.value
|
|
|
|
if (!blobUrl) {
|
|
const response = await fetch(resultImage.value)
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`)
|
|
}
|
|
const blob = await response.blob()
|
|
blobUrl = URL.createObjectURL(blob)
|
|
resultImageBlobUrl.value = blobUrl
|
|
}
|
|
|
|
const link = document.createElement('a')
|
|
link.href = blobUrl
|
|
link.download = `background-added-${Date.now()}.png`
|
|
link.style.display = 'none'
|
|
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
|
|
requestAnimationFrame(() => {
|
|
document.body.removeChild(link)
|
|
})
|
|
} catch (error: any) {
|
|
message.error(t('Failed to download image'))
|
|
}
|
|
}
|
|
|
|
const removeHistoryItem = (index: number) => {
|
|
if (index < 0 || index >= historyList.value.length) return
|
|
|
|
const removingCurrent = currentHistoryIndex.value === index
|
|
historyList.value.splice(index, 1)
|
|
|
|
if (historyList.value.length === 0) {
|
|
currentHistoryIndex.value = -1
|
|
resetUpload()
|
|
return
|
|
}
|
|
|
|
if (removingCurrent) {
|
|
const nextIndex = Math.min(index, historyList.value.length - 1)
|
|
selectHistoryItem(nextIndex)
|
|
} else if (currentHistoryIndex.value > index) {
|
|
currentHistoryIndex.value -= 1
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.add-background-page {
|
|
position: relative;
|
|
width: calc(100% + 40px);
|
|
height: calc(100vh - 64px - 40px);
|
|
margin: -20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.global-drag-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: radial-gradient(circle at center, rgba(248, 250, 252, 0.92), rgba(248, 250, 252, 0.82));
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
animation: fade-in 0.15s ease;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
width: 55vmax;
|
|
height: 55vmax;
|
|
border-radius: 35%;
|
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
animation: rotate 12s linear infinite;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
.overlay-content {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 18px 30px;
|
|
color: #0f172a;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
letter-spacing: 0.04em;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: -10px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(79, 70, 229, 0.35);
|
|
opacity: 0.7;
|
|
animation: pulse-line 1.8s ease-out infinite;
|
|
}
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: -28px 16px;
|
|
border-radius: 999px;
|
|
background: linear-gradient(90deg, rgba(59, 130, 246, 0), rgba(59, 130, 246, 0.24), rgba(59, 130, 246, 0));
|
|
filter: blur(10px);
|
|
opacity: 0.65;
|
|
animation: shimmer 2.4s linear infinite;
|
|
}
|
|
}
|
|
|
|
@keyframes fade-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: scale(0.98);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
@keyframes rotate {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@keyframes pulse-line {
|
|
0% {
|
|
transform: scale(0.9);
|
|
opacity: 0;
|
|
}
|
|
50% {
|
|
transform: scale(1);
|
|
opacity: 0.5;
|
|
}
|
|
100% {
|
|
transform: scale(1.05);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% {
|
|
opacity: 0.2;
|
|
}
|
|
50% {
|
|
opacity: 0.7;
|
|
}
|
|
100% {
|
|
opacity: 0.2;
|
|
}
|
|
}
|
|
|
|
.page-header {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 12px 36px;
|
|
gap: 16px;
|
|
transition: padding-right 0.3s ease;
|
|
}
|
|
|
|
.page-header.has-sidebar {
|
|
padding-right: 316px;
|
|
}
|
|
|
|
.page-header h2 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Header Color Shades */
|
|
.header-color-shades {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
justify-content: center;
|
|
}
|
|
|
|
.shade-item {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
border: 2px solid transparent;
|
|
transition: all 0.15s ease;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0; /* Prevent flex compression */
|
|
|
|
&:hover {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
border-color: #1fc76f;
|
|
}
|
|
|
|
&.active {
|
|
border-color: #1fc76f;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%) rotate(45deg);
|
|
width: 8px;
|
|
height: 14px;
|
|
border: solid #1fc76f;
|
|
border-width: 0 2.5px 2.5px 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
.page-content {
|
|
flex: 1;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
margin: 0 auto;
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
transition: padding-right 0.3s ease;
|
|
}
|
|
|
|
.page-content.has-sidebar {
|
|
padding-right: 296px; /* Make space for right sidebar + padding */
|
|
}
|
|
|
|
.page-header h2 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toolbar-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
background: white;
|
|
color: #64748b;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
|
|
i {
|
|
font-size: 14px;
|
|
}
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
color: #1fc76f;
|
|
background: #f0fdf4;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.95);
|
|
}
|
|
}
|
|
|
|
.tool-container {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
.main-area {
|
|
flex: 1;
|
|
display: flex;
|
|
gap: 0;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
position: relative;
|
|
}
|
|
|
|
.upload-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
background: white;
|
|
border-radius: 0;
|
|
padding: 24px;
|
|
box-shadow: none;
|
|
border: none;
|
|
}
|
|
|
|
.upload-section:has(.preview-section) {
|
|
padding: 0;
|
|
border-radius: 0;
|
|
border: none;
|
|
box-shadow: none;
|
|
background: transparent;
|
|
}
|
|
|
|
.upload-area {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 2px dashed #cbd5e1;
|
|
border-radius: 12px;
|
|
padding: 48px 32px;
|
|
text-align: center;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
background: #fafbfc;
|
|
min-height: 0;
|
|
position: relative;
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
background: #f0fdf4;
|
|
}
|
|
|
|
&.dragging {
|
|
border-color: #1fc76f;
|
|
background: #ecfdf5;
|
|
border-style: solid;
|
|
}
|
|
}
|
|
|
|
.upload-processing-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
border-radius: 12px;
|
|
backdrop-filter: blur(4px);
|
|
z-index: 10;
|
|
|
|
p {
|
|
font-size: 14px;
|
|
color: #64748b;
|
|
margin: 0;
|
|
font-weight: 500;
|
|
}
|
|
}
|
|
|
|
.upload-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16px;
|
|
width: 100%;
|
|
max-width: 500px;
|
|
}
|
|
|
|
.upload-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 20px 40px;
|
|
background: #e6f8f0;
|
|
color: #0d684b;
|
|
border: 1px solid #1fc76f;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
white-space: nowrap;
|
|
|
|
i {
|
|
font-size: 16px;
|
|
}
|
|
|
|
&:hover:not(:disabled) {
|
|
background: #dcfce7;
|
|
border-color: #1fc76f;
|
|
color: #166534;
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15);
|
|
}
|
|
|
|
&:active:not(:disabled) {
|
|
background: #1fc76f;
|
|
border-color: #1fc76f;
|
|
color: white;
|
|
transform: translateY(0);
|
|
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2);
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
background: #f1f5f9;
|
|
border-color: #e2e8f0;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
&:disabled:hover {
|
|
transform: none;
|
|
box-shadow: none;
|
|
}
|
|
}
|
|
|
|
.divider {
|
|
display: flex;
|
|
align-items: center;
|
|
color: #94a3b8;
|
|
font-size: 13px;
|
|
width: 100%;
|
|
max-width: 300px;
|
|
|
|
&::before,
|
|
&::after {
|
|
content: '';
|
|
flex: 1;
|
|
height: 1px;
|
|
background: #e5e7eb;
|
|
}
|
|
|
|
span {
|
|
padding: 0 12px;
|
|
}
|
|
}
|
|
|
|
.upload-hint {
|
|
font-size: 13px;
|
|
color: #64748b;
|
|
margin: 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.upload-format-hint {
|
|
font-size: 12px;
|
|
color: #94a3b8;
|
|
margin: 0;
|
|
}
|
|
|
|
.url-input-wrapper {
|
|
width: 100%;
|
|
max-width: 500px;
|
|
}
|
|
|
|
.url-input {
|
|
width: 100%;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
font-size: 14px;
|
|
color: #1f2937;
|
|
background: white;
|
|
outline: none;
|
|
transition: all 0.2s ease;
|
|
|
|
&::placeholder {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
&:focus {
|
|
border-color: #1fc76f;
|
|
box-shadow: 0 0 0 3px rgba(31, 199, 111, 0.1);
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
background: #f8fafc;
|
|
}
|
|
}
|
|
|
|
.preview-section {
|
|
flex: 1;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
padding: 24px;
|
|
background: #fafbfc;
|
|
}
|
|
|
|
.canvas-container {
|
|
position: relative;
|
|
display: inline-block;
|
|
margin: auto;
|
|
/* Checkerboard pattern for transparent background */
|
|
background-image:
|
|
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
|
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
|
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
|
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
|
background-size: 20px 20px;
|
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
|
background-color: #ffffff;
|
|
padding: 0;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.preview-canvas {
|
|
display: block;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
image-rendering: -webkit-optimize-contrast;
|
|
image-rendering: crisp-edges;
|
|
/* Ensure canvas itself has no background */
|
|
background: transparent;
|
|
}
|
|
|
|
/* Right Sidebar */
|
|
.right-sidebar {
|
|
position: fixed;
|
|
right: 0;
|
|
top: 64px;
|
|
bottom: 0;
|
|
width: 280px;
|
|
background: white;
|
|
border-left: 1px solid #e5e7eb;
|
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.05);
|
|
z-index: 100;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
|
|
:deep(.n-tabs) {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.n-tabs-nav {
|
|
padding: 12px 20px 0;
|
|
}
|
|
|
|
.n-tabs-tab {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.n-tabs-pane-wrapper {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Hide desktop sidebar on mobile */
|
|
.desktop-only {
|
|
display: flex;
|
|
}
|
|
|
|
/* Mobile Color Picker Button - Hidden on desktop */
|
|
.mobile-color-picker-btn {
|
|
display: none;
|
|
}
|
|
|
|
/* Mobile Bottom Sheet */
|
|
.mobile-color-sheet-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
backdrop-filter: blur(2px);
|
|
}
|
|
|
|
.mobile-color-sheet {
|
|
width: 100%;
|
|
max-height: 70vh;
|
|
background: white;
|
|
border-radius: 20px 20px 0 0;
|
|
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sheet-handle {
|
|
width: 40px;
|
|
height: 4px;
|
|
background: #cbd5e1;
|
|
border-radius: 2px;
|
|
margin: 12px auto 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sheet-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 20px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
flex-shrink: 0;
|
|
|
|
h4 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin: 0;
|
|
}
|
|
}
|
|
|
|
.sheet-close-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: #f3f4f6;
|
|
color: #6b7280;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
padding: 0;
|
|
|
|
i {
|
|
font-size: 16px;
|
|
}
|
|
|
|
&:hover {
|
|
background: #e5e7eb;
|
|
color: #374151;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.95);
|
|
}
|
|
}
|
|
|
|
.sheet-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
:deep(.n-tabs) {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.n-tabs-nav {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.n-tabs-tab {
|
|
font-weight: 500;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.n-tabs-pane-wrapper {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Slide up animation */
|
|
.slide-up-enter-active,
|
|
.slide-up-leave-active {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.slide-up-enter-active .mobile-color-sheet,
|
|
.slide-up-leave-active .mobile-color-sheet {
|
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.slide-up-enter-from,
|
|
.slide-up-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.slide-up-enter-from .mobile-color-sheet,
|
|
.slide-up-leave-to .mobile-color-sheet {
|
|
transform: translateY(100%);
|
|
}
|
|
|
|
.slide-up-enter-to .mobile-color-sheet,
|
|
.slide-up-leave-from .mobile-color-sheet {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.sidebar-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
}
|
|
|
|
.color-picker-section,
|
|
.favorite-colors-section,
|
|
.common-colors-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin: 0;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.hex-input-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
}
|
|
|
|
.add-favorite-btn {
|
|
width: 44px;
|
|
height: 44px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
background: white;
|
|
color: #fbbf24;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
|
|
i {
|
|
font-size: 16px;
|
|
}
|
|
|
|
&:hover:not(:disabled) {
|
|
border-color: #fbbf24;
|
|
background: #fffbeb;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
&:active:not(:disabled) {
|
|
transform: scale(0.95);
|
|
background: #fef3c7;
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
color: #cbd5e1;
|
|
}
|
|
}
|
|
|
|
.color-picker-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.sidebar-color-picker {
|
|
width: 100%;
|
|
height: 60px;
|
|
border: 2px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
background: none;
|
|
|
|
&::-webkit-color-swatch-wrapper {
|
|
padding: 0;
|
|
}
|
|
|
|
&::-webkit-color-swatch {
|
|
border: none;
|
|
border-radius: 6px;
|
|
}
|
|
}
|
|
|
|
.sidebar-color-picker-empty {
|
|
width: 100%;
|
|
height: 60px;
|
|
border: 2px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
/* Checkerboard pattern for transparent background */
|
|
background-image:
|
|
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
|
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
|
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
|
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
|
background-size: 20px 20px;
|
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
|
background-color: #ffffff;
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
}
|
|
}
|
|
|
|
.sidebar-hex-input {
|
|
flex: 1;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
font-size: 14px;
|
|
font-family: monospace;
|
|
color: #1f2937;
|
|
background: white;
|
|
outline: none;
|
|
transition: all 0.2s ease;
|
|
text-align: center;
|
|
min-width: 0;
|
|
|
|
&:focus {
|
|
border-color: #1fc76f;
|
|
box-shadow: 0 0 0 3px rgba(31, 199, 111, 0.1);
|
|
}
|
|
|
|
&.invalid {
|
|
border-color: #ef4444;
|
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
|
}
|
|
}
|
|
|
|
.favorite-colors-grid,
|
|
.common-colors-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 10px;
|
|
}
|
|
|
|
.favorite-colors-grid {
|
|
position: relative;
|
|
}
|
|
|
|
.favorite-color-item,
|
|
.common-color-item {
|
|
aspect-ratio: 1;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
border: 2px solid transparent;
|
|
transition: all 0.15s ease;
|
|
position: relative;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: visible;
|
|
|
|
&:hover {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
border-color: #1fc76f;
|
|
}
|
|
|
|
&.active {
|
|
border-color: #1fc76f;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%) rotate(45deg);
|
|
width: 8px;
|
|
height: 14px;
|
|
border: solid #1fc76f;
|
|
border-width: 0 2.5px 2.5px 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
.favorite-color-item {
|
|
.remove-favorite-btn {
|
|
position: absolute;
|
|
top: -8px;
|
|
right: -8px;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
border: 2px solid white;
|
|
background: #ef4444;
|
|
color: white;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transform: scale(0.8);
|
|
transition: all 0.2s ease;
|
|
cursor: pointer;
|
|
font-size: 0;
|
|
line-height: 1;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
z-index: 10;
|
|
|
|
&::before,
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 10px;
|
|
height: 2px;
|
|
background: white;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
&::before {
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
&::after {
|
|
transform: rotate(-45deg);
|
|
}
|
|
|
|
&:hover {
|
|
background: rgba(239, 68, 68, 0.9);
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
&:hover .remove-favorite-btn {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
/* Background Images Sections */
|
|
.background-images-section,
|
|
.custom-backgrounds-section,
|
|
.pexels-images-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
/* Pexels Search Box */
|
|
.pexels-search-box {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.pexels-search-input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: #1f2937;
|
|
background: white;
|
|
transition: all 0.2s ease;
|
|
outline: none;
|
|
|
|
&::placeholder {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
&:focus {
|
|
border-color: #1fc76f;
|
|
box-shadow: 0 0 0 3px rgba(31, 199, 111, 0.1);
|
|
}
|
|
}
|
|
|
|
.pexels-search-btn {
|
|
padding: 10px 16px;
|
|
background: #1fc76f;
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 44px;
|
|
|
|
i {
|
|
font-size: 16px;
|
|
}
|
|
|
|
&:hover:not(:disabled) {
|
|
background: #1ab866;
|
|
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.3);
|
|
}
|
|
|
|
&:active:not(:disabled) {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
.upload-dragger-content {
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
|
|
.upload-text {
|
|
font-size: 13px;
|
|
color: #64748b;
|
|
font-weight: 500;
|
|
}
|
|
}
|
|
|
|
.background-images-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 12px;
|
|
}
|
|
|
|
.background-image-item {
|
|
aspect-ratio: 1 / 1;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
border: 2px solid #e5e7eb;
|
|
transition: all 0.15s ease;
|
|
position: relative;
|
|
overflow: visible;
|
|
background: #f3f4f6;
|
|
|
|
img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.3);
|
|
|
|
.remove-bg-btn {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.remove-bg-btn {
|
|
position: absolute;
|
|
top: -8px;
|
|
right: -8px;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
border: 2px solid white;
|
|
background: #ef4444;
|
|
color: white;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transform: scale(0.9);
|
|
transition: all 0.2s ease;
|
|
cursor: pointer;
|
|
font-size: 0;
|
|
line-height: 1;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
z-index: 10;
|
|
|
|
&::before,
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 10px;
|
|
height: 2px;
|
|
background: white;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
&::before {
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
&::after {
|
|
transform: rotate(-45deg);
|
|
}
|
|
|
|
&:hover {
|
|
background: rgba(239, 68, 68, 0.9);
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
.load-more-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.load-more-btn {
|
|
padding: 10px 24px;
|
|
background: white;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
color: #64748b;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
color: #1fc76f;
|
|
background: #f0fdf4;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.98);
|
|
}
|
|
}
|
|
|
|
.canvas-processing-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
backdrop-filter: blur(4px);
|
|
|
|
p {
|
|
font-size: 14px;
|
|
color: #64748b;
|
|
margin: 0;
|
|
}
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 4px solid #e5e7eb;
|
|
border-top-color: #1fc76f;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.history-bar {
|
|
flex-shrink: 0;
|
|
width: 100%;
|
|
background: transparent;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.history-scroll-container {
|
|
display: flex;
|
|
gap: 16px;
|
|
overflow-x: auto;
|
|
overflow-y: visible;
|
|
padding: 10px;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #cbd5e1 transparent;
|
|
|
|
&::-webkit-scrollbar {
|
|
height: 6px;
|
|
}
|
|
|
|
&::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
background: #cbd5e1;
|
|
border-radius: 3px;
|
|
|
|
&:hover {
|
|
background: #94a3b8;
|
|
}
|
|
}
|
|
}
|
|
|
|
.history-item {
|
|
flex-shrink: 0;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
position: relative;
|
|
|
|
&.add-button {
|
|
background: white;
|
|
border: 2px dashed #cbd5e1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #64748b;
|
|
transition: all 0.2s ease;
|
|
padding: 0;
|
|
margin: 0;
|
|
outline: none;
|
|
-webkit-appearance: none;
|
|
-moz-appearance: none;
|
|
appearance: none;
|
|
|
|
i {
|
|
font-size: 24px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
color: #1fc76f;
|
|
background: #f0fdf4;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-color: #1fc76f;
|
|
}
|
|
}
|
|
|
|
&.active {
|
|
.history-thumbnail {
|
|
border-color: #1fc76f;
|
|
box-shadow: 0 0 10px rgba(31, 199, 111, 0.35);
|
|
}
|
|
}
|
|
}
|
|
|
|
.history-thumbnail {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 8px;
|
|
border: 2px solid #e5e7eb;
|
|
overflow: visible;
|
|
position: relative;
|
|
background:
|
|
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
|
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
|
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
|
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
|
background-size: 10px 10px;
|
|
background-position: 0 0, 0 5px, 5px -5px, -5px 0px;
|
|
transition: all 0.2s ease;
|
|
|
|
img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
display: block;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
&:hover {
|
|
border-color: #1fc76f;
|
|
}
|
|
|
|
.history-delete-btn {
|
|
position: absolute;
|
|
top: -8px;
|
|
right: -8px;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
border: 2px solid white;
|
|
background: #ef4444;
|
|
color: white;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transform: scale(0.9);
|
|
transition: all 0.2s ease;
|
|
cursor: pointer;
|
|
font-size: 0;
|
|
line-height: 1;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
z-index: 10;
|
|
|
|
&::before,
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 10px;
|
|
height: 2px;
|
|
background: white;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
&::before {
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
&::after {
|
|
transform: rotate(-45deg);
|
|
}
|
|
|
|
&:hover {
|
|
background: rgba(239, 68, 68, 0.85);
|
|
}
|
|
}
|
|
|
|
&:hover .history-delete-btn {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
/* Hide desktop sidebar on mobile */
|
|
.desktop-only {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Show mobile color picker button */
|
|
.mobile-color-picker-btn {
|
|
display: flex !important;
|
|
}
|
|
|
|
.page-header {
|
|
padding: 10px 12px;
|
|
flex-wrap: wrap;
|
|
padding-right: 12px !important; /* Reset padding on mobile */
|
|
}
|
|
|
|
.page-header h2 {
|
|
font-size: 18px;
|
|
width: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-color-shades {
|
|
width: 100%;
|
|
justify-content: center; /* Center align the color shades */
|
|
order: 3;
|
|
gap: 4px;
|
|
padding: 8px 0; /* Add vertical padding for separation */
|
|
}
|
|
|
|
.shade-item {
|
|
width: 28px;
|
|
height: 28px;
|
|
flex-shrink: 0; /* Prevent flex compression */
|
|
|
|
&.active::after {
|
|
width: 6px;
|
|
height: 11px;
|
|
border-width: 0 2px 2px 0;
|
|
}
|
|
}
|
|
|
|
.toolbar-actions {
|
|
order: 2;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.page-content {
|
|
padding: 8px 12px;
|
|
padding-right: 12px !important;
|
|
padding-bottom: 12px !important; /* Remove bottom padding since no fixed sidebar */
|
|
}
|
|
|
|
.upload-section {
|
|
padding: 16px;
|
|
}
|
|
|
|
.upload-area {
|
|
padding: 32px 16px;
|
|
}
|
|
|
|
.upload-content {
|
|
gap: 14px;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.upload-btn {
|
|
width: 100%;
|
|
justify-content: center;
|
|
padding: 18px 32px;
|
|
font-size: 15px;
|
|
|
|
i {
|
|
font-size: 15px;
|
|
}
|
|
}
|
|
|
|
.divider {
|
|
max-width: 100%;
|
|
}
|
|
|
|
.url-input-wrapper {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.url-input {
|
|
font-size: 12px;
|
|
padding: 6px 10px;
|
|
}
|
|
|
|
.preview-section {
|
|
padding: 12px;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.canvas-container {
|
|
padding: 0;
|
|
margin: 0 auto; /* Center horizontally */
|
|
display: block; /* Ensure proper centering */
|
|
}
|
|
|
|
.hex-input-wrapper {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.add-favorite-btn {
|
|
width: 44px;
|
|
height: 44px;
|
|
|
|
i {
|
|
font-size: 18px;
|
|
}
|
|
}
|
|
|
|
.sidebar-hex-input {
|
|
font-size: 13px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.favorite-colors-grid,
|
|
.common-colors-grid {
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.history-bar {
|
|
padding: 20px 0;
|
|
}
|
|
|
|
.history-item {
|
|
width: 48px;
|
|
height: 48px;
|
|
}
|
|
|
|
.history-scroll-container {
|
|
gap: 12px;
|
|
padding: 12px 8px;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
|
|
i {
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.main-area {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1024px) and (min-width: 769px) {
|
|
.page-header.has-sidebar {
|
|
padding-right: 276px; /* Adjust for smaller sidebar */
|
|
}
|
|
|
|
.page-content.has-sidebar {
|
|
padding-right: 256px; /* Adjust for smaller sidebar */
|
|
}
|
|
|
|
.right-sidebar {
|
|
width: 240px;
|
|
}
|
|
|
|
.favorite-colors-grid,
|
|
.common-colors-grid {
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 8px;
|
|
}
|
|
}
|
|
</style> |