add_background页面增加右边栏显示当前背景色的统一色调颜色板
This commit is contained in:
parent
6243cb6413
commit
1130d42fd7
@ -42,89 +42,102 @@
|
||||
|
||||
<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"
|
||||
<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"
|
||||
/>
|
||||
</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 && !processing" class="background-controls">
|
||||
<div class="control-group">
|
||||
<label class="control-label">{{ t('Background Color') }}</label>
|
||||
<div class="color-picker-container">
|
||||
>
|
||||
<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
|
||||
v-model="backgroundColor"
|
||||
type="color"
|
||||
class="color-picker"
|
||||
/>
|
||||
<input
|
||||
v-model="backgroundColor"
|
||||
ref="urlInputRef"
|
||||
v-model="imageUrl"
|
||||
type="text"
|
||||
class="hex-input"
|
||||
placeholder="#FFFFFF"
|
||||
@input="validateHexColor"
|
||||
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 class="action-buttons">
|
||||
<button
|
||||
class="apply-btn"
|
||||
@click="applyBackgroundColor"
|
||||
:disabled="processing || !isColorValid"
|
||||
>
|
||||
{{ t('Apply Background') }}
|
||||
</button>
|
||||
<div v-if="processing" class="upload-processing-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('Loading image from URL...') }}</p>
|
||||
</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 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>
|
||||
@ -217,6 +230,71 @@ 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
|
||||
@ -384,6 +462,9 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('paste', handlePaste)
|
||||
if (colorChangeDebounceTimer) {
|
||||
clearTimeout(colorChangeDebounceTimer)
|
||||
}
|
||||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||||
}
|
||||
@ -555,6 +636,10 @@ const initializeCanvas = async () => {
|
||||
fabricCanvas.centerObject(img)
|
||||
fabricCanvas.renderAll()
|
||||
|
||||
// Auto-apply default background color
|
||||
await nextTick()
|
||||
applyBackgroundSilent()
|
||||
|
||||
processing.value = false
|
||||
} catch (error) {
|
||||
console.error('Canvas initialization error:', error)
|
||||
@ -570,26 +655,62 @@ const validateHexColor = () => {
|
||||
return isValid
|
||||
}
|
||||
|
||||
const applyBackgroundColor = async () => {
|
||||
if (!fabricCanvas || !uploadedImage.value) {
|
||||
message.warning(t('Please upload an image first'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateHexColor()) {
|
||||
message.warning(t('Please enter a valid hex color code'))
|
||||
return
|
||||
// Auto-apply background when color changes
|
||||
const onColorChange = () => {
|
||||
if (validateHexColor()) {
|
||||
debouncedApplyBackground()
|
||||
}
|
||||
}
|
||||
|
||||
processing.value = true
|
||||
resultImage.value = ''
|
||||
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) {
|
||||
throw new Error('No image found on canvas')
|
||||
}
|
||||
if (objects.length === 0) return
|
||||
|
||||
// Set background color using fabric v7 API
|
||||
fabricCanvas.backgroundColor = backgroundColor.value
|
||||
@ -605,7 +726,7 @@ const applyBackgroundColor = async () => {
|
||||
resultImage.value = dataUrl
|
||||
await cacheResultImage(dataUrl)
|
||||
|
||||
// Add to history
|
||||
// Update history
|
||||
if (currentHistoryIndex.value === -1 && uploadedImage.value && uploadedImageUrl.value) {
|
||||
const historyItem: HistoryItem = {
|
||||
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
@ -621,14 +742,8 @@ const applyBackgroundColor = async () => {
|
||||
historyList.value[currentHistoryIndex.value].resultImage = dataUrl
|
||||
historyList.value[currentHistoryIndex.value].backgroundColor = backgroundColor.value
|
||||
}
|
||||
|
||||
message.success(t('Background applied successfully'))
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Apply background error:', error)
|
||||
message.error(error.message || t('Failed to apply background'))
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1015,6 +1130,14 @@ const removeHistoryItem = (index: number) => {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -1341,6 +1464,73 @@ const removeHistoryItem = (index: number) => {
|
||||
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;
|
||||
@ -1641,5 +1831,29 @@ const removeHistoryItem = (index: number) => {
|
||||
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>
|
||||
Loading…
x
Reference in New Issue
Block a user