tokimeki-image-editor 0.1.8 → 0.1.10
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.
- package/dist/components/AdjustTool.svelte +333 -299
- package/dist/components/Canvas.svelte +274 -115
- package/dist/components/Canvas.svelte.d.ts +1 -0
- package/dist/components/FilterTool.svelte +408 -298
- package/dist/components/ImageEditor.svelte +426 -423
- package/dist/i18n/locales/en.json +79 -68
- package/dist/i18n/locales/ja.json +79 -68
- package/dist/shaders/blur.wgsl +59 -0
- package/dist/shaders/composite.wgsl +46 -0
- package/dist/shaders/grain.wgsl +225 -0
- package/dist/shaders/image-editor.wgsl +296 -0
- package/dist/types.d.ts +3 -1
- package/dist/utils/adjustments.d.ts +2 -1
- package/dist/utils/adjustments.js +100 -13
- package/dist/utils/canvas.d.ts +7 -2
- package/dist/utils/canvas.js +48 -5
- package/dist/utils/filters.js +109 -2
- package/dist/utils/webgpu-render.d.ts +26 -0
- package/dist/utils/webgpu-render.js +1192 -0
- package/package.json +43 -42
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
65
|
+
const hasTemperature = adjustments.temperature !== 0;
|
|
60
66
|
const hasSepia = adjustments.sepia !== 0;
|
|
61
67
|
const hasGrayscale = adjustments.grayscale !== 0;
|
|
62
|
-
const
|
|
63
|
-
const
|
|
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
|
|
75
|
+
const temperatureAmount = hasTemperature ? adjustments.temperature / 100 : 0;
|
|
67
76
|
const sepiaAmount = adjustments.sepia / 100;
|
|
68
77
|
const grayscaleAmount = adjustments.grayscale / 100;
|
|
69
|
-
const
|
|
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
|
-
|
|
72
|
-
let
|
|
73
|
-
let
|
|
74
|
-
|
|
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
|
-
//
|
|
94
|
+
// 2. Contrast (use 0.5 center for 0-1 range)
|
|
81
95
|
if (hasContrast) {
|
|
82
|
-
r = (
|
|
83
|
-
g = (
|
|
84
|
-
b = (
|
|
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
|
-
//
|
|
100
|
+
// 3. Exposure
|
|
87
101
|
if (hasExposure) {
|
|
88
102
|
r *= exposureFactor;
|
|
89
103
|
g *= exposureFactor;
|
|
90
104
|
b *= exposureFactor;
|
|
91
105
|
}
|
|
92
|
-
//
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
104
|
-
|
|
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
|
-
//
|
|
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 =
|
|
111
|
-
const tg =
|
|
112
|
-
const tb =
|
|
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
|
-
//
|
|
150
|
+
// 7. Grayscale
|
|
118
151
|
if (hasGrayscale) {
|
|
119
|
-
const gray = 0.
|
|
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
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
}
|
|
@@ -243,254 +353,254 @@ function handleFilterSelect(filterId) {
|
|
|
243
353
|
function handleWheel(e) {
|
|
244
354
|
e.stopPropagation();
|
|
245
355
|
}
|
|
246
|
-
</script>
|
|
247
|
-
|
|
248
|
-
<div class="filter-tool" onwheel={handleWheel}>
|
|
249
|
-
<div class="tool-header">
|
|
250
|
-
<h3>{$_('editor.filter')}</h3>
|
|
251
|
-
<button class="close-btn" onclick={onClose} title={$_('editor.close')}>
|
|
252
|
-
<X size={20} />
|
|
253
|
-
</button>
|
|
254
|
-
</div>
|
|
255
|
-
|
|
256
|
-
<div class="filter-info">
|
|
257
|
-
<p class="info-text">{$_('filters.info')}</p>
|
|
258
|
-
</div>
|
|
259
|
-
|
|
260
|
-
{#if isGenerating && filterPreviews.size === 0}
|
|
261
|
-
<div class="loading-message">
|
|
262
|
-
<p>Generating previews...</p>
|
|
263
|
-
</div>
|
|
264
|
-
{/if}
|
|
265
|
-
|
|
266
|
-
<div class="filter-grid">
|
|
267
|
-
{#each FILTER_PRESETS as preset}
|
|
268
|
-
<button
|
|
269
|
-
class="filter-card"
|
|
270
|
-
class:active={selectedFilterId === preset.id}
|
|
271
|
-
onclick={() => handleFilterSelect(preset.id)}
|
|
272
|
-
>
|
|
273
|
-
<div class="filter-preview">
|
|
274
|
-
{#if filterPreviews.has(preset.id)}
|
|
275
|
-
<img
|
|
276
|
-
src={filterPreviews.get(preset.id)}
|
|
277
|
-
alt={$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
|
|
278
|
-
class="preview-image"
|
|
279
|
-
/>
|
|
280
|
-
<div class="filter-name-overlay">
|
|
281
|
-
{$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
|
|
282
|
-
</div>
|
|
283
|
-
{:else}
|
|
284
|
-
<div class="filter-name-loading">
|
|
285
|
-
{$_(preset.id === 'none' ? 'editor.none' : `filters.${preset.id}`)}
|
|
286
|
-
{#if isGenerating}
|
|
287
|
-
<div class="loading-spinner"></div>
|
|
288
|
-
{/if}
|
|
289
|
-
</div>
|
|
290
|
-
{/if}
|
|
291
|
-
</div>
|
|
292
|
-
</button>
|
|
293
|
-
{/each}
|
|
294
|
-
</div>
|
|
295
|
-
</div>
|
|
296
|
-
|
|
297
|
-
<style>
|
|
298
|
-
.filter-tool {
|
|
299
|
-
display: flex;
|
|
300
|
-
flex-direction: column;
|
|
301
|
-
gap: 1rem;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.tool-header {
|
|
305
|
-
display: flex;
|
|
306
|
-
justify-content: space-between;
|
|
307
|
-
align-items: center;
|
|
308
|
-
position: sticky;
|
|
309
|
-
top: 0;
|
|
310
|
-
z-index: 1;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
.tool-header h3 {
|
|
314
|
-
margin: 0;
|
|
315
|
-
font-size: 1.1rem;
|
|
316
|
-
color: #fff;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
.close-btn {
|
|
320
|
-
display: flex;
|
|
321
|
-
align-items: center;
|
|
322
|
-
justify-content: center;
|
|
323
|
-
padding: 0.25rem;
|
|
324
|
-
background: transparent;
|
|
325
|
-
border: none;
|
|
326
|
-
color: #999;
|
|
327
|
-
cursor: pointer;
|
|
328
|
-
border-radius: 4px;
|
|
329
|
-
transition: all 0.2s;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
.close-btn:hover {
|
|
333
|
-
background: #444;
|
|
334
|
-
color: #fff;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
.filter-grid {
|
|
338
|
-
display: grid;
|
|
339
|
-
grid-template-columns: repeat(2, 120px);
|
|
340
|
-
gap: 1rem;
|
|
341
|
-
padding-bottom: 1rem;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
@media (max-width: 767px) {
|
|
345
|
-
|
|
346
|
-
.filter-grid {
|
|
347
|
-
grid-template-columns: repeat(3, 1fr);
|
|
348
|
-
gap: 0.5rem
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
.filter-card {
|
|
353
|
-
display: flex;
|
|
354
|
-
flex-direction: column;
|
|
355
|
-
padding: 0;
|
|
356
|
-
background: #333;
|
|
357
|
-
border: 2px solid #444;
|
|
358
|
-
border-radius: 8px;
|
|
359
|
-
cursor: pointer;
|
|
360
|
-
transition: all 0.2s;
|
|
361
|
-
overflow: hidden;
|
|
362
|
-
flex: 0 0 auto;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
@media (max-width: 767px) {
|
|
366
|
-
|
|
367
|
-
.filter-card {
|
|
368
|
-
border-width: 1px
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
.filter-card:hover {
|
|
373
|
-
border-color: #555;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
.filter-card.active {
|
|
377
|
-
border-color: #0066cc;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
.filter-preview {
|
|
381
|
-
position: relative;
|
|
382
|
-
width: 120px;
|
|
383
|
-
height: 120px;
|
|
384
|
-
display: flex;
|
|
385
|
-
align-items: center;
|
|
386
|
-
justify-content: center;
|
|
387
|
-
overflow: hidden;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
@media (max-width: 767px) {
|
|
391
|
-
|
|
392
|
-
.filter-preview {
|
|
393
|
-
width: 100%;
|
|
394
|
-
height: 0;
|
|
395
|
-
padding-bottom: 100%
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
.preview-image {
|
|
400
|
-
width: 100%;
|
|
401
|
-
height: 100%;
|
|
402
|
-
object-fit: cover;
|
|
403
|
-
display: block;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
@media (max-width: 767px) {
|
|
407
|
-
|
|
408
|
-
.preview-image {
|
|
409
|
-
position: absolute;
|
|
410
|
-
top: 0;
|
|
411
|
-
left: 0
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
.filter-name-loading {
|
|
416
|
-
display: flex;
|
|
417
|
-
flex-direction: column;
|
|
418
|
-
align-items: center;
|
|
419
|
-
gap: 0.5rem;
|
|
420
|
-
font-size: 0.9rem;
|
|
421
|
-
font-weight: 600;
|
|
422
|
-
color: #fff;
|
|
423
|
-
text-align: center;
|
|
424
|
-
padding: 1rem;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
@media (max-width: 767px) {
|
|
428
|
-
|
|
429
|
-
.filter-name-loading {
|
|
430
|
-
position: absolute;
|
|
431
|
-
top: 50%;
|
|
432
|
-
left: 50%;
|
|
433
|
-
transform: translate(-50%, -50%);
|
|
434
|
-
padding: 0.5rem;
|
|
435
|
-
font-size: 0.75rem
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
.filter-name-overlay {
|
|
440
|
-
position: absolute;
|
|
441
|
-
bottom: 0;
|
|
442
|
-
left: 0;
|
|
443
|
-
right: 0;
|
|
444
|
-
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.6) 50%, transparent 100%);
|
|
445
|
-
color: #fff;
|
|
446
|
-
padding: 0.5rem 0.25rem 0.25rem;
|
|
447
|
-
font-size: 0.75rem;
|
|
448
|
-
font-weight: 600;
|
|
449
|
-
text-align: center;
|
|
450
|
-
pointer-events: none;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
@media (max-width: 767px) {
|
|
454
|
-
|
|
455
|
-
.filter-name-overlay {
|
|
456
|
-
font-size: 0.65rem;
|
|
457
|
-
padding: 0.3rem 0.2rem 0.2rem
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.loading-message {
|
|
462
|
-
text-align: center;
|
|
463
|
-
padding: 1rem;
|
|
464
|
-
color: #999;
|
|
465
|
-
font-size: 0.9rem;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
.loading-message p {
|
|
469
|
-
margin: 0;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
.loading-spinner {
|
|
473
|
-
width: 16px;
|
|
474
|
-
height: 16px;
|
|
475
|
-
border: 2px solid #444;
|
|
476
|
-
border-top-color: #0066cc;
|
|
477
|
-
border-radius: 50%;
|
|
478
|
-
animation: spin 0.8s linear infinite;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
@keyframes spin {
|
|
482
|
-
to { transform: rotate(360deg); }
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
.filter-info {
|
|
486
|
-
padding: 0.75rem;
|
|
487
|
-
background: rgba(0, 102, 204, 0.1);
|
|
488
|
-
border-left: 3px solid var(--primary-color, #63b97b);
|
|
489
|
-
border-radius: 4px;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
.info-text {
|
|
493
|
-
margin: 0;
|
|
494
|
-
font-size: 0.85rem;
|
|
495
|
-
color: #ccc;
|
|
496
|
-
}</style>
|
|
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>
|