jingrowtools/src/views/tools/remove_background/remove_background.vue
2026-01-03 00:07:32 +08:00

1641 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="remove-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 remove background') }}</p>
</div>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<div class="page-header">
<h2>{{ t('Remove 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="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">
<div class="comparison-view" v-if="resultImage">
<div class="comparison-container" ref="comparisonContainerRef">
<div
class="comparison-image original-image"
:style="{ clipPath: `inset(0 ${100 - splitPosition}% 0 0)` }"
>
<img
ref="originalImageRef"
:src="uploadedImageUrl"
alt="Original"
@load="adjustContainerSize"
/>
</div>
<div
class="comparison-image result-image"
:style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }"
>
<img
ref="resultImageRef"
:src="resultImageUrl"
alt="Result"
@load="adjustContainerSize"
/>
</div>
<div
class="split-line"
:class="{ 'dragging': isDraggingSplitLine }"
:style="{ left: `${splitPosition}%` }"
@mousedown.prevent.stop="handleSplitLineMouseDown"
>
<div class="split-line-handle">
<i class="fa fa-arrows-h"></i>
</div>
</div>
</div>
</div>
<div v-else class="single-image-view">
<div class="image-wrapper">
<img :src="uploadedImageUrl" alt="Original" />
<div v-if="processing" class="processing-overlay">
<div class="spinner"></div>
<p>{{ t('Processing...') }}</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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } 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'
const message = useMessage()
const authStore = useAuthStore()
const route = useRoute()
// SEO配置 - 直接在组件中定义立即同步设置到HTML
// 符合国内搜索引擎百度、360、搜狗和未来趋势
useSEO({
title: t('Remove Background'),
description: t('Remove background from images using AI technology. Free online tool to remove image backgrounds instantly. Supports JPG, PNG, WebP formats.'),
keywords: t('remove background, AI background removal, online background remover, image processing, background removal tool, transparent background, free tool, JPG background removal, PNG background removal')
})
interface HistoryItem {
id: string
originalImageUrl: string
originalImageFile: File | null
resultImage: string
timestamp: number
}
const fileInputRef = ref<HTMLInputElement | null>(null)
const urlInputRef = ref<HTMLInputElement | null>(null)
const uploadedImage = ref<File | null>(null)
const uploadedImageUrl = ref<string>('')
const resultImage = ref<string>('')
const resultImageBlobUrl = ref<string>('') // 缓存的 blob URL用于下载
const imageUrl = ref<string>('')
const resultImageUrl = computed(() => {
if (!resultImage.value) return ''
// 直接返回图片URL
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 splitPosition = ref(0)
const comparisonContainerRef = ref<HTMLElement | null>(null)
const originalImageRef = ref<HTMLImageElement | null>(null)
const resultImageRef = ref<HTMLImageElement | null>(null)
const isDraggingSplitLine = ref(false)
const adjustContainerSize = () => {
const container = comparisonContainerRef.value
if (!container) return
const img = originalImageRef.value || resultImageRef.value
if (!img) return
const previewSection = container.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 - padding)
if (maxAvailableWidth <= 0 || maxAvailableHeight <= 0) return
if (!img.complete) {
img.addEventListener('load', adjustContainerSize, { once: true })
return
}
const { naturalWidth, naturalHeight } = img
if (naturalWidth === 0 || naturalHeight === 0) return
const scale = Math.min(
maxAvailableWidth / naturalWidth,
maxAvailableHeight / naturalHeight,
1
)
container.style.width = `${naturalWidth * scale}px`
container.style.height = `${naturalHeight * scale}px`
}
watch([uploadedImageUrl, resultImageUrl], adjustContainerSize, { immediate: true })
const handleResize = () => {
adjustContainerSize()
}
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
}
}
// 检查是否有文本URL
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 = ''
splitPosition.value = 0
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
}
// 转换为File对象
const originalFile = new File([blob], 'image-from-url', { type: blob.type })
// 压缩图片到 1024x1024
try {
const compressedFile = await compressImageFile(originalFile, {
maxWidth: 1024,
maxHeight: 1024,
quality: 0.92,
mode: 'contain'
})
uploadedImage.value = compressedFile
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
// 开始处理
await handleRemoveBackground()
} catch (error) {
console.error('图片压缩失败:', error)
message.error('图片处理失败,请重试')
processing.value = false
}
} catch (error: any) {
console.error('加载图片URL失败:', error)
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)
// 清理对象URL
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(uploadedImageUrl.value)
}
// 清理结果图片的 blob URL 缓存
if (resultImageBlobUrl.value) {
URL.revokeObjectURL(resultImageBlobUrl.value)
}
})
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
}
// 压缩图片到 1024x1024
try {
const compressedFile = await compressImageFile(file, {
maxWidth: 1024,
maxHeight: 1024,
quality: 0.92,
mode: 'contain'
})
uploadedImage.value = compressedFile
resultImage.value = ''
splitPosition.value = 0
currentHistoryIndex.value = -1
const reader = new FileReader()
reader.onload = (e) => {
uploadedImageUrl.value = e.target?.result as string
handleRemoveBackground()
}
reader.readAsDataURL(compressedFile)
} catch (error) {
console.error('图片压缩失败:', error)
message.error('图片处理失败,请重试')
}
}
const resetUpload = () => {
// 清理对象URL
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(uploadedImageUrl.value)
}
// 清理结果图片的 blob URL 缓存
if (resultImageBlobUrl.value) {
URL.revokeObjectURL(resultImageBlobUrl.value)
resultImageBlobUrl.value = ''
}
uploadedImage.value = null
uploadedImageUrl.value = ''
resultImage.value = ''
imageUrl.value = ''
splitPosition.value = 0
currentHistoryIndex.value = -1
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
if (comparisonContainerRef.value) {
comparisonContainerRef.value.style.width = ''
comparisonContainerRef.value.style.height = ''
}
}
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
splitPosition.value = 0
uploadedImage.value = item.originalImageFile
// 缓存历史记录的结果图片
if (item.resultImage) {
await cacheResultImage(item.resultImage)
}
}
const getHistoryThumbnailUrl = (item: HistoryItem): string => {
if (item.resultImage) {
return item.resultImage
}
return item.originalImageUrl
}
const handleSplitLineMouseDown = (e: MouseEvent) => {
e.preventDefault()
isDraggingSplitLine.value = true
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!comparisonContainerRef.value || !isDraggingSplitLine.value) return
const rect = comparisonContainerRef.value.getBoundingClientRect()
const x = moveEvent.clientX - rect.left
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
splitPosition.value = percentage
}
const handleMouseUp = () => {
isDraggingSplitLine.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleRemoveBackground = async () => {
if (!authStore.isLoggedIn) {
message.error(t('Please login first to use this feature'))
return
}
if (!uploadedImage.value) {
message.warning(t('Please upload an image first'))
return
}
processing.value = true
resultImage.value = ''
// 处理成功结果的辅助函数(文件内部使用)
const handleSuccess = async (imageUrl: string): Promise<void> => {
resultImage.value = imageUrl
await cacheResultImage(imageUrl)
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: imageUrl,
timestamp: Date.now()
}
historyList.value.unshift(historyItem)
currentHistoryIndex.value = 0
} else if (currentHistoryIndex.value >= 0) {
historyList.value[currentHistoryIndex.value].resultImage = imageUrl
}
}
try {
const formData = new FormData()
formData.append('file', uploadedImage.value)
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) {
await handleSuccess(result.image_url)
return
} else if (result.error) {
message.error(result.error)
}
} catch (parseError) {
console.error('Failed to parse JSON:', parseError, 'Line:', line)
}
}
}
}
// 处理最后一行
if (buffer.trim()) {
try {
const result = JSON.parse(buffer.trim())
if (result.status === 'success' && result.image_url) {
await handleSuccess(result.image_url)
return
} else {
message.error(result.error || t('Failed to remove background'))
}
} catch (parseError) {
console.error('Failed to parse final JSON:', parseError)
message.error(t('Failed to parse response'))
}
} else {
message.error(t('No image data returned'))
}
} catch (error: any) {
let errorMessage = t('Failed to remove background')
if (error.name === 'AbortError') {
errorMessage = t('Request timeout. Please try again.')
} else if (error.message) {
errorMessage = error.message
}
message.error(errorMessage)
} finally {
processing.value = false
}
}
/**
* 缓存结果图片为 blob URL用于下载
*/
const cacheResultImage = async (imageUrl: string) => {
try {
// 清理旧的 blob URL
if (resultImageBlobUrl.value) {
URL.revokeObjectURL(resultImageBlobUrl.value)
resultImageBlobUrl.value = ''
}
// 获取图片并转换为 blob URL
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) {
console.error('缓存图片失败:', error)
// 缓存失败不影响显示,只是下载时需要重新获取
}
}
const handleDownload = async () => {
if (!resultImage.value) return
try {
// 优先使用缓存的 blob URL
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 = `removed-background-${Date.now()}.png`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 清理:移除 DOM 元素,但不释放 blob URL保留缓存供后续下载使用
requestAnimationFrame(() => {
document.body.removeChild(link)
})
} catch (error: any) {
console.error('下载图片失败:', error)
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">
.remove-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;
}
.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;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
padding: 12px;
}
.preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.preview-card {
background: #f8fafc;
border-radius: 12px;
padding: 20px;
border: 1px solid #e5e7eb;
}
.preview-label {
font-size: 14px;
font-weight: 600;
color: #64748b;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.comparison-view {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.comparison-container {
position: relative;
display: inline-block;
max-width: 100%;
max-height: 100%;
overflow: hidden;
border-radius: 8px;
border: 1px solid #e5e7eb;
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: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
.comparison-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
}
.original-image {
z-index: 1;
}
.result-image {
z-index: 2;
}
.split-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: transparent;
cursor: col-resize;
z-index: 10;
transform: translateX(-50%);
transition: left 0s;
user-select: none;
pointer-events: auto;
&:hover {
width: 2px;
}
&.dragging {
transition: none;
cursor: col-resize;
}
}
.split-line-handle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 2px solid #1fc76f;
i {
color: #1fc76f;
font-size: 16px;
}
}
.single-image-view {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
overflow: hidden;
border-radius: 8px;
border: 1px solid #e5e7eb;
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: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.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;
}
.url-submit-btn {
width: 28px;
height: 28px;
i {
font-size: 11px;
}
}
.preview-section {
padding: 8px;
}
.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;
}
}
}
</style>