1859 lines
41 KiB
Vue

<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">
<h2>{{ t('Add Background') }}</h2>
<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
class="toolbar-btn"
@click="resetUpload"
:title="t('Change Image')"
>
<i class="fa fa-refresh"></i>
</button>
</div>
</div>
<div class="page-content">
<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('Loading image from URL...') }}</p>
</div>
</div>
<div v-else class="preview-section">
<!-- Background Color Selection -->
<div v-if="uploadedImage" class="background-controls">
<div class="control-group">
<label class="control-label">{{ t('Background Color') }}</label>
<div class="color-picker-container">
<input
v-model="backgroundColor"
type="color"
class="color-picker"
@input="onColorChange"
/>
<input
v-model="backgroundColor"
type="text"
class="hex-input"
placeholder="#FFFFFF"
@input="onHexInputChange"
@blur="onHexInputBlur"
/>
</div>
</div>
</div>
<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>
<!-- Color Palette Sidebar -->
<div v-if="uploadedImage" class="color-palette-sidebar">
<div class="palette-section">
<h4 class="palette-title">{{ t('Color Tones') }}</h4>
<div class="palette-grid">
<div
v-for="(color, index) in colorPalette"
:key="index"
class="palette-color"
:class="{ 'active': color === backgroundColor }"
:style="{ backgroundColor: color }"
@click="selectPaletteColor(color)"
:title="color"
>
</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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useMessage } 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'
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
// Generate color palette based on HSL color space
const colorPalette = computed(() => {
return generateColorPalette(backgroundColor.value)
})
// HSL color utilities - efficient and modern approach
const hexToHsl = (hex: string): { h: number; s: number; l: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
if (!result) return { h: 0, s: 0, l: 100 }
let r = parseInt(result[1], 16) / 255
let g = parseInt(result[2], 16) / 255
let b = parseInt(result[3], 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0, s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
case g: h = ((b - r) / d + 2) / 6; break
case b: h = ((r - g) / d + 4) / 6; break
}
}
return { h: h * 360, s: s * 100, l: l * 100 }
}
const hslToHex = (h: number, s: number, l: number): string => {
s /= 100
l /= 100
const a = s * Math.min(l, 1 - l)
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color).toString(16).padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase()
}
const generateColorPalette = (baseColor: string): string[] => {
const { h, s } = hexToHsl(baseColor)
const palette: string[] = []
// Generate 12 colors with same hue, varying saturation and lightness
const lightnessLevels = [95, 85, 75, 65, 55, 45, 35, 25, 15, 10, 5]
const saturationVariants = [s, Math.min(100, s + 15), Math.max(0, s - 15)]
// Main tones (same saturation, different lightness)
for (const l of lightnessLevels) {
palette.push(hslToHex(h, s, l))
}
// Add some saturation variants
palette.push(hslToHex(h, Math.min(100, s + 20), 50))
palette.push(hslToHex(h, Math.max(20, s - 20), 50))
return palette
}
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)
let fabricCanvas: Canvas | null = null
const adjustCanvasSize = () => {
const canvas = canvasRef.value
if (!canvas) return
const previewSection = canvas.closest('.preview-section') as HTMLElement
if (!previewSection) return
const previewRect = previewSection.getBoundingClientRect()
const padding = 24
const maxAvailableWidth = Math.max(0, previewRect.width - padding)
const maxAvailableHeight = Math.max(0, previewRect.height - 200) // Account for controls
if (maxAvailableWidth <= 0 || maxAvailableHeight <= 0) return
canvas.style.maxWidth = `${maxAvailableWidth}px`
canvas.style.maxHeight = `${maxAvailableHeight}px`
}
watch(uploadedImageUrl, adjustCanvasSize)
const handleResize = () => {
adjustCanvasSize()
}
const handlePaste = async (event: ClipboardEvent) => {
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
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
await initializeCanvas()
} 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)
})
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
}
})
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'
})
uploadedImage.value = compressedFile
resultImage.value = ''
currentHistoryIndex.value = -1
const reader = new FileReader()
reader.onload = async (e) => {
uploadedImageUrl.value = e.target?.result as string
await initializeCanvas()
}
reader.readAsDataURL(compressedFile)
} catch (error) {
message.error(t('Image processing failed, please try again'))
}
}
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
}
const previewSection = canvasRef.value?.closest('.preview-section') as HTMLElement
if (!previewSection) {
processing.value = false
return
}
const previewRect = previewSection.getBoundingClientRect()
const padding = 100
const maxAvailableWidth = Math.max(400, previewRect.width - padding)
const maxAvailableHeight = Math.max(400, previewRect.height - 300)
const imgWidth = img.width || 1
const imgHeight = img.height || 1
const scale = Math.min(
maxAvailableWidth / imgWidth,
maxAvailableHeight / imgHeight,
1
)
img.scale(scale)
const canvasWidth = img.getScaledWidth()
const canvasHeight = img.getScaledHeight()
// Use setDimensions for fabric v7
fabricCanvas.setDimensions({ width: canvasWidth, height: canvasHeight })
img.set({
left: 0,
top: 0,
selectable: false,
evented: false
})
fabricCanvas.add(img)
fabricCanvas.centerObject(img)
fabricCanvas.renderAll()
// 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 selectPaletteColor = (color: string) => {
backgroundColor.value = color
applyBackgroundSilent()
}
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 = ''
}
}
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)
}
// Initialize canvas and apply saved background color
await initializeCanvasWithBackground(item.backgroundColor)
}
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
}
const previewSection = canvasRef.value?.closest('.preview-section') as HTMLElement
if (!previewSection) {
processing.value = false
return
}
const previewRect = previewSection.getBoundingClientRect()
const padding = 100
const maxAvailableWidth = Math.max(400, previewRect.width - padding)
const maxAvailableHeight = Math.max(400, previewRect.height - 300)
const imgWidth = img.width || 1
const imgHeight = img.height || 1
const scale = Math.min(
maxAvailableWidth / imgWidth,
maxAvailableHeight / imgHeight,
1
)
img.scale(scale)
const canvasWidth = img.getScaledWidth()
const canvasHeight = img.getScaledHeight()
// Use setDimensions for fabric v7
fabricCanvas.setDimensions({ width: canvasWidth, height: canvasHeight })
img.set({
left: 0,
top: 0,
selectable: false,
evented: false
})
// Apply saved background color if provided
if (bgColor) {
fabricCanvas.backgroundColor = bgColor
}
fabricCanvas.add(img)
fabricCanvas.centerObject(img)
fabricCanvas.renderAll()
processing.value = false
} catch (error) {
console.error('Canvas initialization 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;
}
.page-content {
flex: 1;
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.page-header h2 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 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: 16px;
overflow: hidden;
min-height: 0;
}
.upload-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
}
.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;
overflow: hidden;
min-height: 0;
padding: 12px;
}
.background-controls {
flex-shrink: 0;
padding: 20px;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
}
.control-group {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.control-label {
font-size: 14px;
font-weight: 600;
color: #64748b;
min-width: 120px;
}
.color-picker-container {
display: flex;
align-items: center;
gap: 12px;
}
.color-picker {
width: 48px;
height: 48px;
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;
}
}
.hex-input {
width: 120px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
font-family: monospace;
color: #1f2937;
background: white;
outline: none;
transition: all 0.2s ease;
&: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);
}
}
.action-buttons {
display: flex;
justify-content: center;
}
.apply-btn {
padding: 12px 32px;
background: #1fc76f;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #16a34a;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(31, 199, 111, 0.3);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.canvas-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: auto;
min-height: 400px;
padding: 20px;
}
.preview-canvas {
max-width: 100%;
max-height: 100%;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Color Palette Sidebar */
.color-palette-sidebar {
width: 180px;
flex-shrink: 0;
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
overflow-y: auto;
max-height: 100%;
}
.palette-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.palette-title {
font-size: 13px;
font-weight: 600;
color: #374151;
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.palette-color {
aspect-ratio: 1;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s ease;
position: relative;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1;
}
&.active {
border-color: #1fc76f;
box-shadow: 0 0 0 2px rgba(31, 199, 111, 0.3);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background: white;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
}
.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%;
padding: 12px 0;
background: transparent;
display: flex;
justify-content: center;
}
.history-scroll-container {
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
padding: 0;
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: hidden;
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;
}
&:hover {
border-color: #1fc76f;
}
.history-delete-btn {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 999px;
border: none;
background: rgba(15, 23, 42, 0.6);
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;
&::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) {
.page-header {
padding: 10px 12px;
}
.page-header h2 {
font-size: 18px;
}
.page-content {
padding: 8px 12px;
}
.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: 8px;
}
.background-controls {
padding: 16px;
}
.control-group {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.control-label {
min-width: auto;
}
.color-picker-container {
width: 100%;
}
.hex-input {
flex: 1;
}
.history-bar {
padding: 10px 0;
}
.history-item {
width: 48px;
height: 48px;
}
.history-scroll-container {
gap: 8px;
padding: 0 8px;
}
.toolbar-btn {
width: 32px;
height: 32px;
i {
font-size: 12px;
}
}
.main-area {
flex-direction: column;
}
.color-palette-sidebar {
width: 100%;
max-height: 120px;
order: -1;
}
.palette-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.color-palette-sidebar {
width: 140px;
}
.palette-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>