tokimeki-image-editor 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
2
  import { FILTER_PRESETS, applyFilterPreset, matchesFilterPreset } from '../utils/filters';
3
+ import { applyGaussianBlur } from '../utils/adjustments';
3
4
  import { X } from 'lucide-svelte';
4
5
  let { image, adjustments, transform, cropArea, onChange, onClose } = $props();
5
6
  // Find currently selected filter (if any)
@@ -35,98 +36,204 @@ function createSimpleThumb() {
35
36
  }
36
37
  }
37
38
  // Apply adjustments to canvas via pixel manipulation (Safari-compatible)
38
- function applyAdjustmentsToCanvas(ctx, canvas, adjustments) {
39
+ // Must match the shader order and calculations EXACTLY
40
+ function applyAdjustmentsToCanvas(ctx, canvas, adjustments, sourceImageSize) {
39
41
  // Skip if no adjustments needed
40
- if (adjustments.exposure === 0 &&
42
+ if (adjustments.brightness === 0 &&
41
43
  adjustments.contrast === 0 &&
44
+ adjustments.exposure === 0 &&
42
45
  adjustments.highlights === 0 &&
43
46
  adjustments.shadows === 0 &&
44
- adjustments.brightness === 0 &&
45
47
  adjustments.saturation === 0 &&
46
- adjustments.hue === 0 &&
47
- adjustments.vignette === 0 &&
48
+ adjustments.temperature === 0 &&
48
49
  adjustments.sepia === 0 &&
49
- adjustments.grayscale === 0) {
50
+ adjustments.grayscale === 0 &&
51
+ adjustments.vignette === 0 &&
52
+ adjustments.blur === 0 &&
53
+ adjustments.grain === 0) {
50
54
  return;
51
55
  }
52
56
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
53
57
  const data = imageData.data;
54
58
  // Pre-calculate adjustment factors
55
- const hasExposure = adjustments.exposure !== 0;
56
- const hasContrast = adjustments.contrast !== 0;
57
59
  const hasBrightness = adjustments.brightness !== 0;
60
+ const hasContrast = adjustments.contrast !== 0;
61
+ const hasExposure = adjustments.exposure !== 0;
62
+ const hasShadows = adjustments.shadows !== 0;
63
+ const hasHighlights = adjustments.highlights !== 0;
58
64
  const hasSaturation = adjustments.saturation !== 0;
59
- const hasHue = adjustments.hue !== 0;
65
+ const hasTemperature = adjustments.temperature !== 0;
60
66
  const hasSepia = adjustments.sepia !== 0;
61
67
  const hasGrayscale = adjustments.grayscale !== 0;
62
- const exposureFactor = hasExposure ? Math.pow(2, adjustments.exposure / 100) : 1;
63
- const contrastFactor = hasContrast ? 1 + (adjustments.contrast / 200) : 1;
68
+ const hasVignette = adjustments.vignette !== 0;
69
+ const hasBlur = adjustments.blur > 0;
70
+ const hasGrain = adjustments.grain > 0;
64
71
  const brightnessFactor = hasBrightness ? 1 + (adjustments.brightness / 200) : 1;
72
+ const contrastFactor = hasContrast ? 1 + (adjustments.contrast / 200) : 1;
73
+ const exposureFactor = hasExposure ? Math.pow(2, adjustments.exposure / 100) : 1;
65
74
  const saturationFactor = hasSaturation ? adjustments.saturation / 100 : 0;
66
- const hueShift = adjustments.hue;
75
+ const temperatureAmount = hasTemperature ? adjustments.temperature / 100 : 0;
67
76
  const sepiaAmount = adjustments.sepia / 100;
68
77
  const grayscaleAmount = adjustments.grayscale / 100;
69
- const needsHSL = hasSaturation || hasHue;
78
+ const vignetteFactor = hasVignette ? adjustments.vignette / 100 : 0;
79
+ const grainAmount = hasGrain ? adjustments.grain / 100 : 0;
80
+ // Canvas dimensions for vignette
81
+ const centerX = canvas.width / 2;
82
+ const centerY = canvas.height / 2;
70
83
  for (let i = 0; i < data.length; i += 4) {
71
- let r = data[i];
72
- let g = data[i + 1];
73
- let b = data[i + 2];
74
- // Apply brightness
84
+ // Normalize to 0-1 range (match shader)
85
+ let r = data[i] / 255;
86
+ let g = data[i + 1] / 255;
87
+ let b = data[i + 2] / 255;
88
+ // 1. Brightness (FIRST, like shader)
75
89
  if (hasBrightness) {
76
90
  r *= brightnessFactor;
77
91
  g *= brightnessFactor;
78
92
  b *= brightnessFactor;
79
93
  }
80
- // Apply contrast
94
+ // 2. Contrast (use 0.5 center for 0-1 range)
81
95
  if (hasContrast) {
82
- r = ((r - 128) * contrastFactor) + 128;
83
- g = ((g - 128) * contrastFactor) + 128;
84
- b = ((b - 128) * contrastFactor) + 128;
96
+ r = (r - 0.5) * contrastFactor + 0.5;
97
+ g = (g - 0.5) * contrastFactor + 0.5;
98
+ b = (b - 0.5) * contrastFactor + 0.5;
85
99
  }
86
- // Apply exposure
100
+ // 3. Exposure
87
101
  if (hasExposure) {
88
102
  r *= exposureFactor;
89
103
  g *= exposureFactor;
90
104
  b *= exposureFactor;
91
105
  }
92
- // Apply saturation and hue via HSL
93
- if (needsHSL) {
94
- r = Math.max(0, Math.min(255, r));
95
- g = Math.max(0, Math.min(255, g));
96
- b = Math.max(0, Math.min(255, b));
97
- const [h, s, l] = rgbToHsl(r, g, b);
98
- let newH = h;
99
- let newS = s;
100
- if (hasSaturation) {
101
- newS = Math.max(0, Math.min(100, s * (1 + saturationFactor)));
106
+ // 4. Shadows and Highlights
107
+ if (hasShadows || hasHighlights) {
108
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
109
+ if (hasShadows) {
110
+ const shadowMask = Math.pow(1.0 - luma, 2.0);
111
+ r = r - r * (adjustments.shadows / 100) * shadowMask * 0.5;
112
+ g = g - g * (adjustments.shadows / 100) * shadowMask * 0.5;
113
+ b = b - b * (adjustments.shadows / 100) * shadowMask * 0.5;
102
114
  }
103
- if (hasHue) {
104
- newH = (h + hueShift + 360) % 360;
115
+ if (hasHighlights) {
116
+ const highlightMask = Math.pow(luma, 2.0);
117
+ r = r + r * (adjustments.highlights / 100) * highlightMask * 0.5;
118
+ g = g + g * (adjustments.highlights / 100) * highlightMask * 0.5;
119
+ b = b + b * (adjustments.highlights / 100) * highlightMask * 0.5;
105
120
  }
106
- [r, g, b] = hslToRgb(newH, newS, l);
107
121
  }
108
- // Apply sepia
122
+ // 5. Saturation (via HSL)
123
+ if (hasSaturation) {
124
+ // Clamp before HSL conversion
125
+ r = Math.max(0, Math.min(1, r));
126
+ g = Math.max(0, Math.min(1, g));
127
+ b = Math.max(0, Math.min(1, b));
128
+ const [h, s, l] = rgbToHsl(r * 255, g * 255, b * 255);
129
+ const newS = Math.max(0, Math.min(100, s * (1 + saturationFactor)));
130
+ [r, g, b] = hslToRgb(h, newS, l);
131
+ // Result is 0-255, normalize back to 0-1
132
+ r /= 255;
133
+ g /= 255;
134
+ b /= 255;
135
+ }
136
+ // 5.5. Color Temperature
137
+ if (hasTemperature) {
138
+ r = r + temperatureAmount * 0.1;
139
+ b = b - temperatureAmount * 0.1;
140
+ }
141
+ // 6. Sepia
109
142
  if (hasSepia) {
110
- const tr = (0.393 * r + 0.769 * g + 0.189 * b);
111
- const tg = (0.349 * r + 0.686 * g + 0.168 * b);
112
- const tb = (0.272 * r + 0.534 * g + 0.131 * b);
143
+ const tr = 0.393 * r + 0.769 * g + 0.189 * b;
144
+ const tg = 0.349 * r + 0.686 * g + 0.168 * b;
145
+ const tb = 0.272 * r + 0.534 * g + 0.131 * b;
113
146
  r = r * (1 - sepiaAmount) + tr * sepiaAmount;
114
147
  g = g * (1 - sepiaAmount) + tg * sepiaAmount;
115
148
  b = b * (1 - sepiaAmount) + tb * sepiaAmount;
116
149
  }
117
- // Apply grayscale
150
+ // 7. Grayscale
118
151
  if (hasGrayscale) {
119
- const gray = 0.299 * r + 0.587 * g + 0.114 * b;
152
+ const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
120
153
  r = r * (1 - grayscaleAmount) + gray * grayscaleAmount;
121
154
  g = g * (1 - grayscaleAmount) + gray * grayscaleAmount;
122
155
  b = b * (1 - grayscaleAmount) + gray * grayscaleAmount;
123
156
  }
124
- // Clamp final values
125
- data[i] = Math.max(0, Math.min(255, r));
126
- data[i + 1] = Math.max(0, Math.min(255, g));
127
- data[i + 2] = Math.max(0, Math.min(255, b));
157
+ // 8. Vignette
158
+ if (hasVignette) {
159
+ // Calculate pixel position from index
160
+ const pixelIndex = i / 4;
161
+ const x = (pixelIndex % canvas.width) / canvas.width;
162
+ const y = Math.floor(pixelIndex / canvas.width) / canvas.height;
163
+ const dx = x - 0.5;
164
+ const dy = y - 0.5;
165
+ const dist = Math.sqrt(dx * dx + dy * dy);
166
+ const vignetteAmount = Math.pow(dist * 1.4, 2.0);
167
+ r = r * (1.0 + vignetteFactor * vignetteAmount * 1.5);
168
+ g = g * (1.0 + vignetteFactor * vignetteAmount * 1.5);
169
+ b = b * (1.0 + vignetteFactor * vignetteAmount * 1.5);
170
+ }
171
+ // Clamp values before grain processing
172
+ data[i] = Math.max(0, Math.min(255, r * 255));
173
+ data[i + 1] = Math.max(0, Math.min(255, g * 255));
174
+ data[i + 2] = Math.max(0, Math.min(255, b * 255));
128
175
  }
176
+ // Put adjusted image data back to canvas
129
177
  ctx.putImageData(imageData, 0, 0);
178
+ // 8.5. Apply Gaussian blur to entire image if blur adjustment is enabled
179
+ if (hasBlur) {
180
+ const blurAmount = adjustments.blur / 100;
181
+ // Map blur 0-100 to radius 0-10
182
+ const blurRadius = blurAmount * 10.0;
183
+ if (blurRadius > 0.1) {
184
+ applyGaussianBlur(canvas, 0, 0, canvas.width, canvas.height, blurRadius);
185
+ }
186
+ }
187
+ // 9. Film Grain - Applied after blur for sharp grain on top
188
+ if (hasGrain) {
189
+ // Get image data after blur has been applied
190
+ const grainedData = ctx.getImageData(0, 0, canvas.width, canvas.height);
191
+ const gData = grainedData.data;
192
+ // Map canvas pixel to source image coordinates
193
+ // The preview shows a center-cropped square of the original image
194
+ const minDim = Math.min(sourceImageSize.width, sourceImageSize.height);
195
+ const sx = (sourceImageSize.width - minDim) / 2;
196
+ const sy = (sourceImageSize.height - minDim) / 2;
197
+ // Helper function for hash
198
+ const hash2d = (x, y) => {
199
+ const p3x = (x * 0.1031) % 1;
200
+ const p3y = (y * 0.1030) % 1;
201
+ const p3z = (x * 0.0973) % 1;
202
+ const dotP3 = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33);
203
+ return ((p3x + p3y) * p3z + dotP3) % 1;
204
+ };
205
+ for (let i = 0; i < gData.length; i += 4) {
206
+ let r = gData[i] / 255;
207
+ let g = gData[i + 1] / 255;
208
+ let b = gData[i + 2] / 255;
209
+ const pixelIndex = i / 4;
210
+ const canvasX = pixelIndex % canvas.width;
211
+ const canvasY = Math.floor(pixelIndex / canvas.width);
212
+ // Calculate corresponding position in source image
213
+ const imageX = sx + (canvasX / canvas.width) * minDim;
214
+ const imageY = sy + (canvasY / canvas.height) * minDim;
215
+ // Calculate luminance for grain masking
216
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
217
+ // Grain visibility mask: most visible in midtones
218
+ let lumaMask = 1.0 - Math.abs(luma - 0.5) * 2.0;
219
+ lumaMask = Math.pow(lumaMask, 0.5); // Softer falloff
220
+ // Multi-scale grain for organic film look
221
+ const fineGrain = hash2d(Math.floor(imageX / 2.5), Math.floor(imageY / 2.5)) - 0.5;
222
+ const mediumGrain = hash2d(Math.floor(imageX / 5.5) + 123.45, Math.floor(imageY / 5.5) + 678.90) - 0.5;
223
+ const coarseGrain = hash2d(Math.floor(imageX / 9.0) + 345.67, Math.floor(imageY / 9.0) + 890.12) - 0.5;
224
+ // Combine grain layers
225
+ const grainNoise = fineGrain * 0.5 + mediumGrain * 0.3 + coarseGrain * 0.2;
226
+ // Strong grain intensity
227
+ const strength = lumaMask * grainAmount * 0.5;
228
+ r += grainNoise * strength;
229
+ g += grainNoise * strength;
230
+ b += grainNoise * strength;
231
+ gData[i] = Math.max(0, Math.min(255, r * 255));
232
+ gData[i + 1] = Math.max(0, Math.min(255, g * 255));
233
+ gData[i + 2] = Math.max(0, Math.min(255, b * 255));
234
+ }
235
+ ctx.putImageData(grainedData, 0, 0);
236
+ }
130
237
  }
131
238
  // RGB to HSL conversion
132
239
  function rgbToHsl(r, g, b) {
@@ -216,11 +323,14 @@ async function generateFilterPreviews() {
216
323
  canvas.width = PREVIEW_SIZE;
217
324
  canvas.height = PREVIEW_SIZE;
218
325
  const ctx = canvas.getContext('2d');
219
- if (ctx) {
326
+ if (ctx && image) {
220
327
  // Draw base image first
221
328
  ctx.drawImage(baseImg, 0, 0);
222
329
  // Apply filters via pixel manipulation (Safari-compatible)
223
- applyAdjustmentsToCanvas(ctx, canvas, presetAdjustments);
330
+ applyAdjustmentsToCanvas(ctx, canvas, presetAdjustments, {
331
+ width: image.width,
332
+ height: image.height
333
+ });
224
334
  filterPreviews.set(preset.id, canvas.toDataURL('image/jpeg', 0.7));
225
335
  filterPreviews = new Map(filterPreviews);
226
336
  }
@@ -239,254 +349,258 @@ function handleFilterSelect(filterId) {
239
349
  onChange(newAdjustments);
240
350
  }
241
351
  }
242
- </script>
243
-
244
- <div class="filter-tool">
245
- <div class="tool-header">
246
- <h3>{$_('editor.filter')}</h3>
247
- <button class="close-btn" onclick={onClose} title={$_('editor.close')}>
248
- <X size={20} />
249
- </button>
250
- </div>
251
-
252
- <div class="filter-info">
253
- <p class="info-text">{$_('filters.info')}</p>
254
- </div>
255
-
256
- {#if isGenerating && filterPreviews.size === 0}
257
- <div class="loading-message">
258
- <p>Generating previews...</p>
259
- </div>
260
- {/if}
261
-
262
- <div class="filter-grid">
263
- {#each FILTER_PRESETS as preset}
264
- <button
265
- class="filter-card"
266
- class:active={selectedFilterId === preset.id}
267
- onclick={() => handleFilterSelect(preset.id)}
268
- >
269
- <div class="filter-preview">
270
- {#if filterPreviews.has(preset.id)}
271
- <img
272
- src={filterPreviews.get(preset.id)}
273
- alt={$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
274
- class="preview-image"
275
- />
276
- <div class="filter-name-overlay">
277
- {$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
278
- </div>
279
- {:else}
280
- <div class="filter-name-loading">
281
- {$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
282
- {#if isGenerating}
283
- <div class="loading-spinner"></div>
284
- {/if}
285
- </div>
286
- {/if}
287
- </div>
288
- </button>
289
- {/each}
290
- </div>
291
- </div>
292
-
293
- <style>
294
- .filter-tool {
295
- display: flex;
296
- flex-direction: column;
297
- gap: 1rem;
298
- }
299
-
300
- .tool-header {
301
- display: flex;
302
- justify-content: space-between;
303
- align-items: center;
304
- position: sticky;
305
- top: 0;
306
- z-index: 1;
307
- }
308
-
309
- .tool-header h3 {
310
- margin: 0;
311
- font-size: 1.1rem;
312
- color: #fff;
313
- }
314
-
315
- .close-btn {
316
- display: flex;
317
- align-items: center;
318
- justify-content: center;
319
- padding: 0.25rem;
320
- background: transparent;
321
- border: none;
322
- color: #999;
323
- cursor: pointer;
324
- border-radius: 4px;
325
- transition: all 0.2s;
326
- }
327
-
328
- .close-btn:hover {
329
- background: #444;
330
- color: #fff;
331
- }
332
-
333
- .filter-grid {
334
- display: grid;
335
- grid-template-columns: repeat(2, 120px);
336
- gap: 1rem;
337
- padding-bottom: 1rem;
338
- }
339
-
340
- @media (max-width: 767px) {
341
-
342
- .filter-grid {
343
- grid-template-columns: repeat(3, 1fr);
344
- gap: 0.5rem
345
- }
346
- }
347
-
348
- .filter-card {
349
- display: flex;
350
- flex-direction: column;
351
- padding: 0;
352
- background: #333;
353
- border: 2px solid #444;
354
- border-radius: 8px;
355
- cursor: pointer;
356
- transition: all 0.2s;
357
- overflow: hidden;
358
- flex: 0 0 auto;
359
- }
360
-
361
- @media (max-width: 767px) {
362
-
363
- .filter-card {
364
- border-width: 1px
365
- }
366
- }
367
-
368
- .filter-card:hover {
369
- border-color: #555;
370
- }
371
-
372
- .filter-card.active {
373
- border-color: #0066cc;
374
- }
375
-
376
- .filter-preview {
377
- position: relative;
378
- width: 120px;
379
- height: 120px;
380
- display: flex;
381
- align-items: center;
382
- justify-content: center;
383
- overflow: hidden;
384
- }
385
-
386
- @media (max-width: 767px) {
387
-
388
- .filter-preview {
389
- width: 100%;
390
- height: 0;
391
- padding-bottom: 100%
392
- }
393
- }
394
-
395
- .preview-image {
396
- width: 100%;
397
- height: 100%;
398
- object-fit: cover;
399
- display: block;
400
- }
401
-
402
- @media (max-width: 767px) {
403
-
404
- .preview-image {
405
- position: absolute;
406
- top: 0;
407
- left: 0
408
- }
409
- }
410
-
411
- .filter-name-loading {
412
- display: flex;
413
- flex-direction: column;
414
- align-items: center;
415
- gap: 0.5rem;
416
- font-size: 0.9rem;
417
- font-weight: 600;
418
- color: #fff;
419
- text-align: center;
420
- padding: 1rem;
421
- }
422
-
423
- @media (max-width: 767px) {
424
-
425
- .filter-name-loading {
426
- position: absolute;
427
- top: 50%;
428
- left: 50%;
429
- transform: translate(-50%, -50%);
430
- padding: 0.5rem;
431
- font-size: 0.75rem
432
- }
433
- }
434
-
435
- .filter-name-overlay {
436
- position: absolute;
437
- bottom: 0;
438
- left: 0;
439
- right: 0;
440
- background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.6) 50%, transparent 100%);
441
- color: #fff;
442
- padding: 0.5rem 0.25rem 0.25rem;
443
- font-size: 0.75rem;
444
- font-weight: 600;
445
- text-align: center;
446
- pointer-events: none;
447
- }
448
-
449
- @media (max-width: 767px) {
450
-
451
- .filter-name-overlay {
452
- font-size: 0.65rem;
453
- padding: 0.3rem 0.2rem 0.2rem
454
- }
455
- }
456
-
457
- .loading-message {
458
- text-align: center;
459
- padding: 1rem;
460
- color: #999;
461
- font-size: 0.9rem;
462
- }
463
-
464
- .loading-message p {
465
- margin: 0;
466
- }
467
-
468
- .loading-spinner {
469
- width: 16px;
470
- height: 16px;
471
- border: 2px solid #444;
472
- border-top-color: #0066cc;
473
- border-radius: 50%;
474
- animation: spin 0.8s linear infinite;
475
- }
476
-
477
- @keyframes spin {
478
- to { transform: rotate(360deg); }
479
- }
480
-
481
- .filter-info {
482
- padding: 0.75rem;
483
- background: rgba(0, 102, 204, 0.1);
484
- border-left: 3px solid var(--primary-color, #63b97b);
485
- border-radius: 4px;
486
- }
487
-
488
- .info-text {
489
- margin: 0;
490
- font-size: 0.85rem;
491
- color: #ccc;
492
- }</style>
352
+ // Prevent wheel events from propagating to canvas zoom handler
353
+ function handleWheel(e) {
354
+ e.stopPropagation();
355
+ }
356
+ </script>
357
+
358
+ <div class="filter-tool" onwheel={handleWheel}>
359
+ <div class="tool-header">
360
+ <h3>{$_('editor.filter')}</h3>
361
+ <button class="close-btn" onclick={onClose} title={$_('editor.close')}>
362
+ <X size={20} />
363
+ </button>
364
+ </div>
365
+
366
+ <div class="filter-info">
367
+ <p class="info-text">{$_('filters.info')}</p>
368
+ </div>
369
+
370
+ {#if isGenerating && filterPreviews.size === 0}
371
+ <div class="loading-message">
372
+ <p>Generating previews...</p>
373
+ </div>
374
+ {/if}
375
+
376
+ <div class="filter-grid">
377
+ {#each FILTER_PRESETS as preset}
378
+ <button
379
+ class="filter-card"
380
+ class:active={selectedFilterId === preset.id}
381
+ onclick={() => handleFilterSelect(preset.id)}
382
+ >
383
+ <div class="filter-preview">
384
+ {#if filterPreviews.has(preset.id)}
385
+ <img
386
+ src={filterPreviews.get(preset.id)}
387
+ alt={$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
388
+ class="preview-image"
389
+ />
390
+ <div class="filter-name-overlay">
391
+ {$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
392
+ </div>
393
+ {:else}
394
+ <div class="filter-name-loading">
395
+ {$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
396
+ {#if isGenerating}
397
+ <div class="loading-spinner"></div>
398
+ {/if}
399
+ </div>
400
+ {/if}
401
+ </div>
402
+ </button>
403
+ {/each}
404
+ </div>
405
+ </div>
406
+
407
+ <style>
408
+ .filter-tool {
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: 1rem;
412
+ }
413
+
414
+ .tool-header {
415
+ display: flex;
416
+ justify-content: space-between;
417
+ align-items: center;
418
+ position: sticky;
419
+ top: 0;
420
+ z-index: 1;
421
+ }
422
+
423
+ .tool-header h3 {
424
+ margin: 0;
425
+ font-size: 1.1rem;
426
+ color: #fff;
427
+ }
428
+
429
+ .close-btn {
430
+ display: flex;
431
+ align-items: center;
432
+ justify-content: center;
433
+ padding: 0.25rem;
434
+ background: transparent;
435
+ border: none;
436
+ color: #999;
437
+ cursor: pointer;
438
+ border-radius: 4px;
439
+ transition: all 0.2s;
440
+ }
441
+
442
+ .close-btn:hover {
443
+ background: #444;
444
+ color: #fff;
445
+ }
446
+
447
+ .filter-grid {
448
+ display: grid;
449
+ grid-template-columns: repeat(2, 120px);
450
+ gap: 1rem;
451
+ padding-bottom: 1rem;
452
+ }
453
+
454
+ @media (max-width: 767px) {
455
+
456
+ .filter-grid {
457
+ grid-template-columns: repeat(3, 1fr);
458
+ gap: 0.5rem
459
+ }
460
+ }
461
+
462
+ .filter-card {
463
+ display: flex;
464
+ flex-direction: column;
465
+ padding: 0;
466
+ background: #333;
467
+ border: 2px solid #444;
468
+ border-radius: 8px;
469
+ cursor: pointer;
470
+ transition: all 0.2s;
471
+ overflow: hidden;
472
+ flex: 0 0 auto;
473
+ }
474
+
475
+ @media (max-width: 767px) {
476
+
477
+ .filter-card {
478
+ border-width: 1px
479
+ }
480
+ }
481
+
482
+ .filter-card:hover {
483
+ border-color: #555;
484
+ }
485
+
486
+ .filter-card.active {
487
+ border-color: #0066cc;
488
+ }
489
+
490
+ .filter-preview {
491
+ position: relative;
492
+ width: 120px;
493
+ height: 120px;
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: center;
497
+ overflow: hidden;
498
+ }
499
+
500
+ @media (max-width: 767px) {
501
+
502
+ .filter-preview {
503
+ width: 100%;
504
+ height: 0;
505
+ padding-bottom: 100%
506
+ }
507
+ }
508
+
509
+ .preview-image {
510
+ width: 100%;
511
+ height: 100%;
512
+ object-fit: cover;
513
+ display: block;
514
+ }
515
+
516
+ @media (max-width: 767px) {
517
+
518
+ .preview-image {
519
+ position: absolute;
520
+ top: 0;
521
+ left: 0
522
+ }
523
+ }
524
+
525
+ .filter-name-loading {
526
+ display: flex;
527
+ flex-direction: column;
528
+ align-items: center;
529
+ gap: 0.5rem;
530
+ font-size: 0.9rem;
531
+ font-weight: 600;
532
+ color: #fff;
533
+ text-align: center;
534
+ padding: 1rem;
535
+ }
536
+
537
+ @media (max-width: 767px) {
538
+
539
+ .filter-name-loading {
540
+ position: absolute;
541
+ top: 50%;
542
+ left: 50%;
543
+ transform: translate(-50%, -50%);
544
+ padding: 0.5rem;
545
+ font-size: 0.75rem
546
+ }
547
+ }
548
+
549
+ .filter-name-overlay {
550
+ position: absolute;
551
+ bottom: 0;
552
+ left: 0;
553
+ right: 0;
554
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.6) 50%, transparent 100%);
555
+ color: #fff;
556
+ padding: 0.5rem 0.25rem 0.25rem;
557
+ font-size: 0.75rem;
558
+ font-weight: 600;
559
+ text-align: center;
560
+ pointer-events: none;
561
+ }
562
+
563
+ @media (max-width: 767px) {
564
+
565
+ .filter-name-overlay {
566
+ font-size: 0.65rem;
567
+ padding: 0.3rem 0.2rem 0.2rem
568
+ }
569
+ }
570
+
571
+ .loading-message {
572
+ text-align: center;
573
+ padding: 1rem;
574
+ color: #999;
575
+ font-size: 0.9rem;
576
+ }
577
+
578
+ .loading-message p {
579
+ margin: 0;
580
+ }
581
+
582
+ .loading-spinner {
583
+ width: 16px;
584
+ height: 16px;
585
+ border: 2px solid #444;
586
+ border-top-color: #0066cc;
587
+ border-radius: 50%;
588
+ animation: spin 0.8s linear infinite;
589
+ }
590
+
591
+ @keyframes spin {
592
+ to { transform: rotate(360deg); }
593
+ }
594
+
595
+ .filter-info {
596
+ padding: 0.75rem;
597
+ background: rgba(0, 102, 204, 0.1);
598
+ border-left: 3px solid var(--primary-color, #63b97b);
599
+ border-radius: 4px;
600
+ }
601
+
602
+ .info-text {
603
+ margin: 0;
604
+ font-size: 0.85rem;
605
+ color: #ccc;
606
+ }</style>