tokimeki-image-editor 0.1.0 → 0.1.2
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 +317 -0
- package/dist/components/AdjustTool.svelte.d.ts +9 -0
- package/dist/components/BlurTool.svelte +613 -0
- package/dist/components/BlurTool.svelte.d.ts +15 -0
- package/dist/components/Canvas.svelte +214 -0
- package/dist/components/Canvas.svelte.d.ts +17 -0
- package/dist/components/CropTool.svelte +942 -0
- package/dist/components/CropTool.svelte.d.ts +14 -0
- package/dist/components/ExportTool.svelte +191 -0
- package/dist/components/ExportTool.svelte.d.ts +10 -0
- package/dist/components/FilterTool.svelte +492 -0
- package/dist/components/FilterTool.svelte.d.ts +12 -0
- package/dist/components/ImageEditor.svelte +735 -0
- package/dist/components/ImageEditor.svelte.d.ts +12 -0
- package/dist/components/RotateTool.svelte +157 -0
- package/dist/components/RotateTool.svelte.d.ts +9 -0
- package/dist/components/StampTool.svelte +678 -0
- package/dist/components/StampTool.svelte.d.ts +15 -0
- package/dist/components/Toolbar.svelte +136 -0
- package/dist/components/Toolbar.svelte.d.ts +10 -0
- package/dist/config/stamps.d.ts +2 -0
- package/dist/config/stamps.js +22 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +9 -0
- package/dist/i18n/locales/en.json +68 -0
- package/dist/i18n/locales/ja.json +68 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +1 -0
- package/dist/utils/adjustments.d.ts +26 -0
- package/dist/utils/adjustments.js +525 -0
- package/dist/utils/canvas.d.ts +30 -0
- package/dist/utils/canvas.js +293 -0
- package/dist/utils/filters.d.ts +18 -0
- package/dist/utils/filters.js +114 -0
- package/dist/utils/history.d.ts +15 -0
- package/dist/utils/history.js +67 -0
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CropArea, Viewport, TransformState } from '../types';
|
|
2
|
+
interface Props {
|
|
3
|
+
canvas: HTMLCanvasElement | null;
|
|
4
|
+
image: HTMLImageElement | null;
|
|
5
|
+
viewport: Viewport;
|
|
6
|
+
transform: TransformState;
|
|
7
|
+
onApply: (cropArea: CropArea) => void;
|
|
8
|
+
onCancel: () => void;
|
|
9
|
+
onViewportChange?: (viewport: Partial<Viewport>) => void;
|
|
10
|
+
onTransformChange?: (transform: Partial<TransformState>) => void;
|
|
11
|
+
}
|
|
12
|
+
declare const CropTool: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type CropTool = ReturnType<typeof CropTool>;
|
|
14
|
+
export default CropTool;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script lang="ts">import { _ } from 'svelte-i18n';
|
|
2
|
+
import { Download } from 'lucide-svelte';
|
|
3
|
+
let { options, onChange, onExport, onClose } = $props();
|
|
4
|
+
function handleFormatChange(format) {
|
|
5
|
+
onChange({ format });
|
|
6
|
+
}
|
|
7
|
+
function handleQualityChange(event) {
|
|
8
|
+
const value = parseFloat(event.target.value);
|
|
9
|
+
onChange({ quality: value });
|
|
10
|
+
}
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="export-tool">
|
|
14
|
+
<div class="tool-header">
|
|
15
|
+
<h3>{$_('editor.export')}</h3>
|
|
16
|
+
<button class="close-btn" onclick={onClose}>✕</button>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="tool-content">
|
|
20
|
+
<div class="tool-group">
|
|
21
|
+
<label>{$_('editor.format')}</label>
|
|
22
|
+
<div class="format-buttons">
|
|
23
|
+
<button
|
|
24
|
+
class="format-btn"
|
|
25
|
+
class:active={options.format === 'png'}
|
|
26
|
+
onclick={() => handleFormatChange('png')}
|
|
27
|
+
>
|
|
28
|
+
PNG
|
|
29
|
+
</button>
|
|
30
|
+
<button
|
|
31
|
+
class="format-btn"
|
|
32
|
+
class:active={options.format === 'jpeg'}
|
|
33
|
+
onclick={() => handleFormatChange('jpeg')}
|
|
34
|
+
>
|
|
35
|
+
JPEG
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{#if options.format === 'jpeg'}
|
|
41
|
+
<div class="tool-group">
|
|
42
|
+
<label>
|
|
43
|
+
{$_('editor.quality')}: {Math.round(options.quality * 100)}%
|
|
44
|
+
</label>
|
|
45
|
+
<input
|
|
46
|
+
type="range"
|
|
47
|
+
min="0.1"
|
|
48
|
+
max="1"
|
|
49
|
+
step="0.05"
|
|
50
|
+
value={options.quality}
|
|
51
|
+
oninput={handleQualityChange}
|
|
52
|
+
class="quality-slider"
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
{/if}
|
|
56
|
+
|
|
57
|
+
<button class="export-btn" onclick={onExport}>
|
|
58
|
+
<Download size={20} />
|
|
59
|
+
<span>{$_('editor.download')}</span>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.export-tool {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 1rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.tool-header {
|
|
72
|
+
display: flex;
|
|
73
|
+
justify-content: space-between;
|
|
74
|
+
align-items: center;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.tool-header h3 {
|
|
78
|
+
margin: 0;
|
|
79
|
+
font-size: 1.1rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.close-btn {
|
|
83
|
+
background: none;
|
|
84
|
+
border: none;
|
|
85
|
+
color: #fff;
|
|
86
|
+
font-size: 1.5rem;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
padding: 0;
|
|
89
|
+
width: 30px;
|
|
90
|
+
height: 30px;
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
border-radius: 4px;
|
|
95
|
+
transition: background 0.2s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.close-btn:hover {
|
|
99
|
+
background: #444;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.tool-content {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: 1.5rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.tool-group {
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
gap: 0.5rem;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.tool-group label {
|
|
115
|
+
font-size: 0.9rem;
|
|
116
|
+
color: #ccc;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.format-buttons {
|
|
120
|
+
display: flex;
|
|
121
|
+
gap: 0.5rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.format-btn {
|
|
125
|
+
flex: 1;
|
|
126
|
+
padding: 0.5rem 1rem;
|
|
127
|
+
background: #333;
|
|
128
|
+
color: #fff;
|
|
129
|
+
border: 1px solid #444;
|
|
130
|
+
border-radius: 4px;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
transition: all 0.2s;
|
|
133
|
+
font-size: 0.9rem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.format-btn:hover {
|
|
137
|
+
background: #444;
|
|
138
|
+
border-color: #555;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.format-btn.active {
|
|
142
|
+
background: var(--primary-color, #63b97b);
|
|
143
|
+
border-color: var(--primary-color, #63b97b);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.quality-slider {
|
|
147
|
+
width: 100%;
|
|
148
|
+
height: 6px;
|
|
149
|
+
border-radius: 3px;
|
|
150
|
+
background: #444;
|
|
151
|
+
outline: none;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.quality-slider::-webkit-slider-thumb {
|
|
156
|
+
appearance: none;
|
|
157
|
+
width: 16px;
|
|
158
|
+
height: 16px;
|
|
159
|
+
border-radius: 50%;
|
|
160
|
+
background: var(--primary-color, #63b97b);
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.quality-slider::-moz-range-thumb {
|
|
165
|
+
width: 16px;
|
|
166
|
+
height: 16px;
|
|
167
|
+
border-radius: 50%;
|
|
168
|
+
background: var(--primary-color, #63b97b);
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
border: none;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.export-btn {
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
justify-content: center;
|
|
177
|
+
gap: 0.5rem;
|
|
178
|
+
padding: 0.75rem 1.5rem;
|
|
179
|
+
background: var(--primary-color, #63b97b);
|
|
180
|
+
color: #fff;
|
|
181
|
+
border: none;
|
|
182
|
+
border-radius: 4px;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
transition: all 0.2s;
|
|
185
|
+
font-size: 1rem;
|
|
186
|
+
font-weight: 500;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.export-btn:hover {
|
|
190
|
+
background: var(--primary-color, #63b97b);
|
|
191
|
+
}</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExportOptions } from '../types';
|
|
2
|
+
interface Props {
|
|
3
|
+
options: ExportOptions;
|
|
4
|
+
onChange: (options: Partial<ExportOptions>) => void;
|
|
5
|
+
onExport: () => void;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
declare const ExportTool: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type ExportTool = ReturnType<typeof ExportTool>;
|
|
10
|
+
export default ExportTool;
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
<script lang="ts">import { _ } from 'svelte-i18n';
|
|
2
|
+
import { FILTER_PRESETS, applyFilterPreset, matchesFilterPreset } from '../utils/filters';
|
|
3
|
+
import { X } from 'lucide-svelte';
|
|
4
|
+
let { image, adjustments, transform, cropArea, onChange, onClose } = $props();
|
|
5
|
+
// Find currently selected filter (if any)
|
|
6
|
+
let selectedFilterId = $derived(FILTER_PRESETS.find(preset => matchesFilterPreset(adjustments, preset))?.id || null);
|
|
7
|
+
// Generate preview thumbnails for filters
|
|
8
|
+
const PREVIEW_SIZE = 240;
|
|
9
|
+
let filterPreviews = $state(new Map());
|
|
10
|
+
let baseThumbDataUrl = null;
|
|
11
|
+
let isGenerating = $state(false);
|
|
12
|
+
// Create a tiny thumbnail directly from original image (no transforms)
|
|
13
|
+
function createSimpleThumb() {
|
|
14
|
+
if (!image)
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const canvas = document.createElement('canvas');
|
|
18
|
+
const size = PREVIEW_SIZE;
|
|
19
|
+
canvas.width = size;
|
|
20
|
+
canvas.height = size;
|
|
21
|
+
const ctx = canvas.getContext('2d');
|
|
22
|
+
if (!ctx)
|
|
23
|
+
return null;
|
|
24
|
+
// Calculate crop to center square
|
|
25
|
+
const minDim = Math.min(image.width, image.height);
|
|
26
|
+
const sx = (image.width - minDim) / 2;
|
|
27
|
+
const sy = (image.height - minDim) / 2;
|
|
28
|
+
// Draw center square of original image
|
|
29
|
+
ctx.drawImage(image, sx, sy, minDim, minDim, 0, 0, size, size);
|
|
30
|
+
return canvas.toDataURL('image/jpeg', 0.7);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error('Failed to create thumbnail:', error);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Apply adjustments to canvas via pixel manipulation (Safari-compatible)
|
|
38
|
+
function applyAdjustmentsToCanvas(ctx, canvas, adjustments) {
|
|
39
|
+
// Skip if no adjustments needed
|
|
40
|
+
if (adjustments.exposure === 0 &&
|
|
41
|
+
adjustments.contrast === 0 &&
|
|
42
|
+
adjustments.highlights === 0 &&
|
|
43
|
+
adjustments.shadows === 0 &&
|
|
44
|
+
adjustments.brightness === 0 &&
|
|
45
|
+
adjustments.saturation === 0 &&
|
|
46
|
+
adjustments.hue === 0 &&
|
|
47
|
+
adjustments.vignette === 0 &&
|
|
48
|
+
adjustments.sepia === 0 &&
|
|
49
|
+
adjustments.grayscale === 0) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
53
|
+
const data = imageData.data;
|
|
54
|
+
// Pre-calculate adjustment factors
|
|
55
|
+
const hasExposure = adjustments.exposure !== 0;
|
|
56
|
+
const hasContrast = adjustments.contrast !== 0;
|
|
57
|
+
const hasBrightness = adjustments.brightness !== 0;
|
|
58
|
+
const hasSaturation = adjustments.saturation !== 0;
|
|
59
|
+
const hasHue = adjustments.hue !== 0;
|
|
60
|
+
const hasSepia = adjustments.sepia !== 0;
|
|
61
|
+
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;
|
|
64
|
+
const brightnessFactor = hasBrightness ? 1 + (adjustments.brightness / 200) : 1;
|
|
65
|
+
const saturationFactor = hasSaturation ? adjustments.saturation / 100 : 0;
|
|
66
|
+
const hueShift = adjustments.hue;
|
|
67
|
+
const sepiaAmount = adjustments.sepia / 100;
|
|
68
|
+
const grayscaleAmount = adjustments.grayscale / 100;
|
|
69
|
+
const needsHSL = hasSaturation || hasHue;
|
|
70
|
+
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
|
|
75
|
+
if (hasBrightness) {
|
|
76
|
+
r *= brightnessFactor;
|
|
77
|
+
g *= brightnessFactor;
|
|
78
|
+
b *= brightnessFactor;
|
|
79
|
+
}
|
|
80
|
+
// Apply contrast
|
|
81
|
+
if (hasContrast) {
|
|
82
|
+
r = ((r - 128) * contrastFactor) + 128;
|
|
83
|
+
g = ((g - 128) * contrastFactor) + 128;
|
|
84
|
+
b = ((b - 128) * contrastFactor) + 128;
|
|
85
|
+
}
|
|
86
|
+
// Apply exposure
|
|
87
|
+
if (hasExposure) {
|
|
88
|
+
r *= exposureFactor;
|
|
89
|
+
g *= exposureFactor;
|
|
90
|
+
b *= exposureFactor;
|
|
91
|
+
}
|
|
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)));
|
|
102
|
+
}
|
|
103
|
+
if (hasHue) {
|
|
104
|
+
newH = (h + hueShift + 360) % 360;
|
|
105
|
+
}
|
|
106
|
+
[r, g, b] = hslToRgb(newH, newS, l);
|
|
107
|
+
}
|
|
108
|
+
// Apply sepia
|
|
109
|
+
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);
|
|
113
|
+
r = r * (1 - sepiaAmount) + tr * sepiaAmount;
|
|
114
|
+
g = g * (1 - sepiaAmount) + tg * sepiaAmount;
|
|
115
|
+
b = b * (1 - sepiaAmount) + tb * sepiaAmount;
|
|
116
|
+
}
|
|
117
|
+
// Apply grayscale
|
|
118
|
+
if (hasGrayscale) {
|
|
119
|
+
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
120
|
+
r = r * (1 - grayscaleAmount) + gray * grayscaleAmount;
|
|
121
|
+
g = g * (1 - grayscaleAmount) + gray * grayscaleAmount;
|
|
122
|
+
b = b * (1 - grayscaleAmount) + gray * grayscaleAmount;
|
|
123
|
+
}
|
|
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));
|
|
128
|
+
}
|
|
129
|
+
ctx.putImageData(imageData, 0, 0);
|
|
130
|
+
}
|
|
131
|
+
// RGB to HSL conversion
|
|
132
|
+
function rgbToHsl(r, g, b) {
|
|
133
|
+
r /= 255;
|
|
134
|
+
g /= 255;
|
|
135
|
+
b /= 255;
|
|
136
|
+
const max = Math.max(r, g, b);
|
|
137
|
+
const min = Math.min(r, g, b);
|
|
138
|
+
let h = 0;
|
|
139
|
+
let s = 0;
|
|
140
|
+
const l = (max + min) / 2;
|
|
141
|
+
if (max !== min) {
|
|
142
|
+
const d = max - min;
|
|
143
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
144
|
+
switch (max) {
|
|
145
|
+
case r:
|
|
146
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
147
|
+
break;
|
|
148
|
+
case g:
|
|
149
|
+
h = ((b - r) / d + 2) / 6;
|
|
150
|
+
break;
|
|
151
|
+
case b:
|
|
152
|
+
h = ((r - g) / d + 4) / 6;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [h * 360, s * 100, l * 100];
|
|
157
|
+
}
|
|
158
|
+
// HSL to RGB conversion
|
|
159
|
+
function hslToRgb(h, s, l) {
|
|
160
|
+
h /= 360;
|
|
161
|
+
s /= 100;
|
|
162
|
+
l /= 100;
|
|
163
|
+
let r, g, b;
|
|
164
|
+
if (s === 0) {
|
|
165
|
+
r = g = b = l;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const hue2rgb = (p, q, t) => {
|
|
169
|
+
if (t < 0)
|
|
170
|
+
t += 1;
|
|
171
|
+
if (t > 1)
|
|
172
|
+
t -= 1;
|
|
173
|
+
if (t < 1 / 6)
|
|
174
|
+
return p + (q - p) * 6 * t;
|
|
175
|
+
if (t < 1 / 2)
|
|
176
|
+
return q;
|
|
177
|
+
if (t < 2 / 3)
|
|
178
|
+
return p + (q - p) * (2 / 3 - t) * 6;
|
|
179
|
+
return p;
|
|
180
|
+
};
|
|
181
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
182
|
+
const p = 2 * l - q;
|
|
183
|
+
r = hue2rgb(p, q, h + 1 / 3);
|
|
184
|
+
g = hue2rgb(p, q, h);
|
|
185
|
+
b = hue2rgb(p, q, h - 1 / 3);
|
|
186
|
+
}
|
|
187
|
+
return [r * 255, g * 255, b * 255];
|
|
188
|
+
}
|
|
189
|
+
// Generate previews asynchronously
|
|
190
|
+
async function generateFilterPreviews() {
|
|
191
|
+
if (!image || isGenerating)
|
|
192
|
+
return;
|
|
193
|
+
isGenerating = true;
|
|
194
|
+
filterPreviews.clear();
|
|
195
|
+
// Create base thumbnail once
|
|
196
|
+
baseThumbDataUrl = createSimpleThumb();
|
|
197
|
+
if (!baseThumbDataUrl) {
|
|
198
|
+
isGenerating = false;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Load base image
|
|
202
|
+
const baseImg = new Image();
|
|
203
|
+
baseImg.src = baseThumbDataUrl;
|
|
204
|
+
await new Promise((resolve) => {
|
|
205
|
+
baseImg.onload = resolve;
|
|
206
|
+
baseImg.onerror = resolve;
|
|
207
|
+
});
|
|
208
|
+
// Generate each preview with delays
|
|
209
|
+
for (let i = 0; i < FILTER_PRESETS.length; i++) {
|
|
210
|
+
// Yield to browser
|
|
211
|
+
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 10)));
|
|
212
|
+
const preset = FILTER_PRESETS[i];
|
|
213
|
+
const presetAdjustments = applyFilterPreset(preset);
|
|
214
|
+
try {
|
|
215
|
+
const canvas = document.createElement('canvas');
|
|
216
|
+
canvas.width = PREVIEW_SIZE;
|
|
217
|
+
canvas.height = PREVIEW_SIZE;
|
|
218
|
+
const ctx = canvas.getContext('2d');
|
|
219
|
+
if (ctx) {
|
|
220
|
+
// Draw base image first
|
|
221
|
+
ctx.drawImage(baseImg, 0, 0);
|
|
222
|
+
// Apply filters via pixel manipulation (Safari-compatible)
|
|
223
|
+
applyAdjustmentsToCanvas(ctx, canvas, presetAdjustments);
|
|
224
|
+
filterPreviews.set(preset.id, canvas.toDataURL('image/jpeg', 0.7));
|
|
225
|
+
filterPreviews = new Map(filterPreviews);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
console.error(`Failed to generate preview for ${preset.id}:`, error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
isGenerating = false;
|
|
233
|
+
}
|
|
234
|
+
generateFilterPreviews();
|
|
235
|
+
function handleFilterSelect(filterId) {
|
|
236
|
+
const preset = FILTER_PRESETS.find(p => p.id === filterId);
|
|
237
|
+
if (preset) {
|
|
238
|
+
const newAdjustments = applyFilterPreset(preset);
|
|
239
|
+
onChange(newAdjustments);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
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>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AdjustmentsState, CropArea, TransformState } from '../types';
|
|
2
|
+
interface Props {
|
|
3
|
+
image: HTMLImageElement | null;
|
|
4
|
+
adjustments: AdjustmentsState;
|
|
5
|
+
transform: TransformState;
|
|
6
|
+
cropArea?: CropArea | null;
|
|
7
|
+
onChange: (adjustments: AdjustmentsState) => void;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
declare const FilterTool: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type FilterTool = ReturnType<typeof FilterTool>;
|
|
12
|
+
export default FilterTool;
|