tokimeki-image-editor 0.4.1 → 0.4.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 +128 -27
- package/dist/components/AdjustTool.svelte.d.ts +3 -1
- package/dist/components/AnnotationTool.svelte +2 -2
- package/dist/components/Canvas.svelte +22 -4
- package/dist/components/HSLTool.svelte +135 -0
- package/dist/components/HSLTool.svelte.d.ts +8 -0
- package/dist/components/ImageEditor.svelte +41 -9
- package/dist/components/ToneCurveTool.svelte +435 -0
- package/dist/components/ToneCurveTool.svelte.d.ts +8 -0
- package/dist/i18n/locales/en.json +27 -1
- package/dist/i18n/locales/ja.json +27 -1
- package/dist/index.d.ts +1 -1
- package/dist/shaders/blur.d.ts +1 -1
- package/dist/shaders/blur.js +66 -59
- package/dist/shaders/denoise.d.ts +7 -0
- package/dist/shaders/denoise.js +89 -0
- package/dist/shaders/grain.d.ts +1 -1
- package/dist/shaders/grain.js +214 -225
- package/dist/shaders/image-editor.d.ts +1 -1
- package/dist/shaders/image-editor.js +107 -9
- package/dist/shaders/sharpen.d.ts +14 -0
- package/dist/shaders/sharpen.js +95 -0
- package/dist/types.d.ts +30 -0
- package/dist/utils/__tests__/adjustments.test.d.ts +1 -0
- package/dist/utils/__tests__/adjustments.test.js +110 -0
- package/dist/utils/__tests__/editor-core.test.d.ts +1 -0
- package/dist/utils/__tests__/editor-core.test.js +78 -0
- package/dist/utils/__tests__/filters.test.d.ts +1 -0
- package/dist/utils/__tests__/filters.test.js +58 -0
- package/dist/utils/__tests__/history.test.d.ts +1 -0
- package/dist/utils/__tests__/history.test.js +100 -0
- package/dist/utils/__tests__/lod.test.d.ts +1 -0
- package/dist/utils/__tests__/lod.test.js +95 -0
- package/dist/utils/__tests__/viewport.test.d.ts +1 -0
- package/dist/utils/__tests__/viewport.test.js +45 -0
- package/dist/utils/adjustments.d.ts +31 -1
- package/dist/utils/adjustments.js +168 -2
- package/dist/utils/editor-core.js +2 -2
- package/dist/utils/editor-interaction.d.ts +3 -1
- package/dist/utils/editor-interaction.js +19 -11
- package/dist/utils/filters.d.ts +2 -1
- package/dist/utils/filters.js +272 -5
- package/dist/utils/history.d.ts +4 -2
- package/dist/utils/history.js +20 -2
- package/dist/utils/webgpu-render.d.ts +5 -1
- package/dist/utils/webgpu-render.js +453 -20
- package/package.json +6 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script lang="ts">import { _ } from 'svelte-i18n';
|
|
2
|
-
import { Sun, Contrast, Cloud, Moon, SunMedium, Palette, Thermometer, Aperture, Waves, Sparkles } from 'lucide-svelte';
|
|
2
|
+
import { Sun, Contrast, Cloud, Moon, SunMedium, Palette, Thermometer, Aperture, Waves, Sparkles, Focus, AudioWaveform, Spline, Droplets } from 'lucide-svelte';
|
|
3
3
|
import ToolPanel from './ToolPanel.svelte';
|
|
4
4
|
import Slider from './Slider.svelte';
|
|
5
|
+
import ToneCurveTool from './ToneCurveTool.svelte';
|
|
6
|
+
import HSLTool from './HSLTool.svelte';
|
|
5
7
|
import { haptic } from '../utils/haptics';
|
|
6
|
-
let { adjustments, onChange, onClose } = $props();
|
|
8
|
+
let { adjustments, onChange, onClose, onCurveChange, onHSLChange } = $props();
|
|
7
9
|
let activeGroup = $state('light');
|
|
8
10
|
function handleChange(key, value) {
|
|
9
11
|
onChange({ [key]: value });
|
|
@@ -22,7 +24,9 @@ function resetAll() {
|
|
|
22
24
|
sepia: 0,
|
|
23
25
|
grayscale: 0,
|
|
24
26
|
blur: 0,
|
|
25
|
-
grain: 0
|
|
27
|
+
grain: 0,
|
|
28
|
+
sharpen: 0,
|
|
29
|
+
denoise: 0
|
|
26
30
|
});
|
|
27
31
|
}
|
|
28
32
|
// Prevent wheel events from propagating to canvas zoom handler
|
|
@@ -45,31 +49,83 @@ const effectControls = [
|
|
|
45
49
|
{ key: 'blur', icon: Waves, bipolar: false, min: 0, max: 100 },
|
|
46
50
|
{ key: 'grain', icon: Sparkles, bipolar: false, min: 0, max: 100 }
|
|
47
51
|
];
|
|
52
|
+
const detailControls = [
|
|
53
|
+
{ key: 'sharpen', icon: Focus, bipolar: false, min: 0, max: 100 },
|
|
54
|
+
{ key: 'denoise', icon: AudioWaveform, bipolar: false, min: 0, max: 100 }
|
|
55
|
+
];
|
|
48
56
|
let currentControls = $derived(activeGroup === 'light'
|
|
49
57
|
? lightControls
|
|
50
58
|
: activeGroup === 'color'
|
|
51
59
|
? colorControls
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
: activeGroup === 'detail'
|
|
61
|
+
? detailControls
|
|
62
|
+
: effectControls);
|
|
63
|
+
// Quick visual feedback: show how many scalar adjustments are active
|
|
64
|
+
let activeCount = $derived(Object.entries(adjustments).filter(([_, v]) => typeof v === 'number' && v !== 0).length);
|
|
55
65
|
function setGroup(g) {
|
|
56
66
|
if (g === activeGroup)
|
|
57
67
|
return;
|
|
58
68
|
haptic('selection');
|
|
59
69
|
activeGroup = g;
|
|
60
70
|
}
|
|
71
|
+
// Drag-to-scroll for group tabs
|
|
72
|
+
let tabsEl = $state(null);
|
|
73
|
+
let isDraggingTabs = false; // intentionally not reactive — no re-render needed
|
|
74
|
+
let dragActive = false;
|
|
75
|
+
let dragStartX = 0;
|
|
76
|
+
let scrollStartX = 0;
|
|
77
|
+
function handleTabsPointerDown(e) {
|
|
78
|
+
if (!tabsEl)
|
|
79
|
+
return;
|
|
80
|
+
dragActive = true;
|
|
81
|
+
isDraggingTabs = false;
|
|
82
|
+
dragStartX = e.clientX;
|
|
83
|
+
scrollStartX = tabsEl.scrollLeft;
|
|
84
|
+
}
|
|
85
|
+
function handleTabsPointerMove(e) {
|
|
86
|
+
if (!dragActive || !tabsEl)
|
|
87
|
+
return;
|
|
88
|
+
const dx = e.clientX - dragStartX;
|
|
89
|
+
if (!isDraggingTabs && Math.abs(dx) > 5) {
|
|
90
|
+
isDraggingTabs = true;
|
|
91
|
+
}
|
|
92
|
+
if (isDraggingTabs) {
|
|
93
|
+
tabsEl.scrollLeft = scrollStartX - dx;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function handleTabsPointerUp() {
|
|
97
|
+
if (isDraggingTabs) {
|
|
98
|
+
// Reset after a microtask so the click event on the button still sees isDraggingTabs=true
|
|
99
|
+
setTimeout(() => { isDraggingTabs = false; }, 0);
|
|
100
|
+
}
|
|
101
|
+
dragActive = false;
|
|
102
|
+
}
|
|
103
|
+
function guardClick(fn) {
|
|
104
|
+
return () => {
|
|
105
|
+
if (!isDraggingTabs)
|
|
106
|
+
fn();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
61
109
|
</script>
|
|
62
110
|
|
|
63
111
|
<div class="adjust-tool" onwheel={handleWheel}>
|
|
64
112
|
<ToolPanel title={$_('editor.adjust')} {onClose}>
|
|
65
113
|
{#snippet children()}
|
|
66
|
-
<div
|
|
114
|
+
<div
|
|
115
|
+
class="group-tabs"
|
|
116
|
+
role="tablist"
|
|
117
|
+
bind:this={tabsEl}
|
|
118
|
+
onpointerdown={handleTabsPointerDown}
|
|
119
|
+
onpointermove={handleTabsPointerMove}
|
|
120
|
+
onpointerup={handleTabsPointerUp}
|
|
121
|
+
onpointerleave={handleTabsPointerUp}
|
|
122
|
+
>
|
|
67
123
|
<button
|
|
68
124
|
role="tab"
|
|
69
125
|
aria-selected={activeGroup === 'light'}
|
|
70
126
|
class="group-tab"
|
|
71
127
|
class:active={activeGroup === 'light'}
|
|
72
|
-
onclick={() => setGroup('light')}
|
|
128
|
+
onclick={guardClick(() => setGroup('light'))}
|
|
73
129
|
>
|
|
74
130
|
<Sun size={14} />
|
|
75
131
|
<span>Light</span>
|
|
@@ -79,7 +135,7 @@ function setGroup(g) {
|
|
|
79
135
|
aria-selected={activeGroup === 'color'}
|
|
80
136
|
class="group-tab"
|
|
81
137
|
class:active={activeGroup === 'color'}
|
|
82
|
-
onclick={() => setGroup('color')}
|
|
138
|
+
onclick={guardClick(() => setGroup('color'))}
|
|
83
139
|
>
|
|
84
140
|
<Palette size={14} />
|
|
85
141
|
<span>Color</span>
|
|
@@ -89,32 +145,68 @@ function setGroup(g) {
|
|
|
89
145
|
aria-selected={activeGroup === 'effects'}
|
|
90
146
|
class="group-tab"
|
|
91
147
|
class:active={activeGroup === 'effects'}
|
|
92
|
-
onclick={() => setGroup('effects')}
|
|
148
|
+
onclick={guardClick(() => setGroup('effects'))}
|
|
93
149
|
>
|
|
94
150
|
<Sparkles size={14} />
|
|
95
151
|
<span>Effects</span>
|
|
96
152
|
</button>
|
|
153
|
+
<button
|
|
154
|
+
role="tab"
|
|
155
|
+
aria-selected={activeGroup === 'detail'}
|
|
156
|
+
class="group-tab"
|
|
157
|
+
class:active={activeGroup === 'detail'}
|
|
158
|
+
onclick={guardClick(() => setGroup('detail'))}
|
|
159
|
+
>
|
|
160
|
+
<Focus size={14} />
|
|
161
|
+
<span>Detail</span>
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
role="tab"
|
|
165
|
+
aria-selected={activeGroup === 'curve'}
|
|
166
|
+
class="group-tab"
|
|
167
|
+
class:active={activeGroup === 'curve'}
|
|
168
|
+
onclick={guardClick(() => setGroup('curve'))}
|
|
169
|
+
>
|
|
170
|
+
<Spline size={14} />
|
|
171
|
+
<span>Curve</span>
|
|
172
|
+
</button>
|
|
173
|
+
<button
|
|
174
|
+
role="tab"
|
|
175
|
+
aria-selected={activeGroup === 'hsl'}
|
|
176
|
+
class="group-tab"
|
|
177
|
+
class:active={activeGroup === 'hsl'}
|
|
178
|
+
onclick={guardClick(() => setGroup('hsl'))}
|
|
179
|
+
>
|
|
180
|
+
<Droplets size={14} />
|
|
181
|
+
<span>HSL</span>
|
|
182
|
+
</button>
|
|
97
183
|
</div>
|
|
98
184
|
|
|
99
185
|
<div class="control-list">
|
|
100
|
-
{#
|
|
101
|
-
{
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
186
|
+
{#if activeGroup === 'curve'}
|
|
187
|
+
<ToneCurveTool toneCurve={adjustments.toneCurve} onChange={onCurveChange} />
|
|
188
|
+
{:else if activeGroup === 'hsl'}
|
|
189
|
+
<HSLTool hsl={adjustments.hsl} onChange={onHSLChange} />
|
|
190
|
+
{:else}
|
|
191
|
+
{#each currentControls as control (control.key)}
|
|
192
|
+
{@const Icon = control.icon}
|
|
193
|
+
<div class="control-row">
|
|
194
|
+
<div class="control-icon">
|
|
195
|
+
<Icon size={16} strokeWidth={1.8} />
|
|
196
|
+
</div>
|
|
197
|
+
<div class="control-slider">
|
|
198
|
+
<Slider
|
|
199
|
+
label={$_(`adjustments.${control.key}`)}
|
|
200
|
+
value={adjustments[control.key as keyof AdjustmentsState]}
|
|
201
|
+
min={control.min}
|
|
202
|
+
max={control.max}
|
|
203
|
+
bipolar={control.bipolar}
|
|
204
|
+
onInput={(v) => handleChange(control.key as keyof AdjustmentsState, v)}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
105
207
|
</div>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
label={$_(`adjustments.${control.key}`)}
|
|
109
|
-
value={adjustments[control.key as keyof AdjustmentsState]}
|
|
110
|
-
min={control.min}
|
|
111
|
-
max={control.max}
|
|
112
|
-
bipolar={control.bipolar}
|
|
113
|
-
onInput={(v) => handleChange(control.key as keyof AdjustmentsState, v)}
|
|
114
|
-
/>
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
{/each}
|
|
208
|
+
{/each}
|
|
209
|
+
{/if}
|
|
118
210
|
</div>
|
|
119
211
|
{/snippet}
|
|
120
212
|
|
|
@@ -146,6 +238,15 @@ function setGroup(g) {
|
|
|
146
238
|
background: var(--tk-surface-1);
|
|
147
239
|
border-radius: var(--tk-radius-lg);
|
|
148
240
|
margin-bottom: var(--tk-space-3);
|
|
241
|
+
overflow-x: auto;
|
|
242
|
+
scrollbar-width: none;
|
|
243
|
+
-ms-overflow-style: none;
|
|
244
|
+
}
|
|
245
|
+
.group-tabs::-webkit-scrollbar {
|
|
246
|
+
display: none;
|
|
247
|
+
}
|
|
248
|
+
.group-tabs:active {
|
|
249
|
+
cursor: grabbing;
|
|
149
250
|
}
|
|
150
251
|
|
|
151
252
|
.group-tab {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { AdjustmentsState } from '../types';
|
|
1
|
+
import type { AdjustmentsState, ToneCurve, HSLAdjustment } from '../types';
|
|
2
2
|
interface Props {
|
|
3
3
|
adjustments: AdjustmentsState;
|
|
4
4
|
onChange: (adjustments: Partial<AdjustmentsState>) => void;
|
|
5
5
|
onClose: () => void;
|
|
6
|
+
onCurveChange: (curve: ToneCurve) => void;
|
|
7
|
+
onHSLChange: (hsl: HSLAdjustment) => void;
|
|
6
8
|
}
|
|
7
9
|
declare const AdjustTool: import("svelte").Component<Props, {}, "">;
|
|
8
10
|
type AdjustTool = ReturnType<typeof AdjustTool>;
|
|
@@ -741,8 +741,8 @@ function handleTouchMove(event) {
|
|
|
741
741
|
}
|
|
742
742
|
else {
|
|
743
743
|
const scale = distance / interactionState.initialPinchDistance;
|
|
744
|
-
const newZoom = Math.max(0.1, Math.min(
|
|
745
|
-
const delta = newZoom
|
|
744
|
+
const newZoom = Math.max(0.1, Math.min(10, interactionState.initialPinchZoom * scale));
|
|
745
|
+
const delta = Math.log(newZoom / viewport.zoom);
|
|
746
746
|
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
747
747
|
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
748
748
|
const canvasRect = canvas.getBoundingClientRect();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">import { onMount, onDestroy } from 'svelte';
|
|
2
2
|
import { drawImage, preloadStampImage, applyStamps, applyAnnotations } from '../utils/canvas';
|
|
3
|
-
import { initWebGPUCanvas, uploadImageToGPU, renderWithAdjustments, cleanupWebGPU, setCanvasClearColor } from '../utils/webgpu-render';
|
|
3
|
+
import { initWebGPUCanvas, uploadImageToGPU, renderWithAdjustments, cleanupWebGPU, setCanvasClearColor, updateCurveLUT } from '../utils/webgpu-render';
|
|
4
|
+
import { isToneCurveDefault } from '../utils/adjustments';
|
|
4
5
|
import { createEditorInteractionState, handlePureMouseDown, handlePureMouseMove, handlePureMouseUp, handlePureTouchStart, handlePureTouchMove, handlePureTouchEnd, calculateZoomViewport } from '../utils/editor-interaction';
|
|
5
6
|
import { haptic } from '../utils/haptics';
|
|
6
7
|
let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = [], skipAnnotations = false, theme = 'dark', onZoom, onViewportChange } = $props();
|
|
@@ -11,7 +12,16 @@ const CLEAR_COLORS = {
|
|
|
11
12
|
};
|
|
12
13
|
$effect(() => {
|
|
13
14
|
setCanvasClearColor(CLEAR_COLORS[theme]);
|
|
14
|
-
|
|
15
|
+
// Guard: don't trigger 2D render while WebGPU init is in progress,
|
|
16
|
+
// as calling getContext('2d') poisons the canvas for WebGPU.
|
|
17
|
+
if (!isInitializing) {
|
|
18
|
+
if (useWebGPU) {
|
|
19
|
+
renderWebGPU();
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
requestRender();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
});
|
|
16
26
|
// State
|
|
17
27
|
let canvasElement = $state(null);
|
|
@@ -142,10 +152,18 @@ $effect(() => {
|
|
|
142
152
|
});
|
|
143
153
|
}
|
|
144
154
|
});
|
|
155
|
+
// Track last-uploaded curve to avoid redundant LUT writes
|
|
156
|
+
let lastCurveHash = '';
|
|
145
157
|
function renderWebGPU() {
|
|
146
158
|
if (!canvasElement || !webgpuReady || !currentImage)
|
|
147
159
|
return;
|
|
148
160
|
ensureCanvasSize(canvasElement, width, height);
|
|
161
|
+
// Update tone curve LUT if changed
|
|
162
|
+
const curveHash = JSON.stringify(adjustments.toneCurve);
|
|
163
|
+
if (curveHash !== lastCurveHash) {
|
|
164
|
+
lastCurveHash = curveHash;
|
|
165
|
+
updateCurveLUT(adjustments.toneCurve);
|
|
166
|
+
}
|
|
149
167
|
renderWithAdjustments(adjustments, viewport, transform, width, height, currentImage.width, currentImage.height, cropArea, blurAreas);
|
|
150
168
|
const shouldRenderAnnotations = !skipAnnotations && annotations.length > 0;
|
|
151
169
|
if (overlayCanvasElement && (stampAreas.length > 0 || shouldRenderAnnotations)) {
|
|
@@ -225,7 +243,7 @@ function handleTouchStart(e) {
|
|
|
225
243
|
distSq < DOUBLE_TAP_DISTANCE * DOUBLE_TAP_DISTANCE) {
|
|
226
244
|
e.preventDefault();
|
|
227
245
|
const targetZoom = viewport.zoom > 1.05 ? 1 : DOUBLE_TAP_ZOOM_TARGET;
|
|
228
|
-
const delta = targetZoom
|
|
246
|
+
const delta = Math.log(targetZoom / viewport.zoom);
|
|
229
247
|
if (onZoom)
|
|
230
248
|
onZoom(delta, t.clientX, t.clientY);
|
|
231
249
|
haptic('medium');
|
|
@@ -244,7 +262,7 @@ function handleDoubleClick(e) {
|
|
|
244
262
|
return;
|
|
245
263
|
e.preventDefault();
|
|
246
264
|
const targetZoom = viewport.zoom > 1.05 ? 1 : DOUBLE_TAP_ZOOM_TARGET;
|
|
247
|
-
const delta = targetZoom
|
|
265
|
+
const delta = Math.log(targetZoom / viewport.zoom);
|
|
248
266
|
onZoom(delta, e.clientX, e.clientY);
|
|
249
267
|
}
|
|
250
268
|
function handleTouchMove(e) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts">import { _ } from 'svelte-i18n';
|
|
2
|
+
import Slider from './Slider.svelte';
|
|
3
|
+
import { haptic } from '../utils/haptics';
|
|
4
|
+
let { hsl, onChange } = $props();
|
|
5
|
+
let activeColor = $state('red');
|
|
6
|
+
const COLOR_MAP = [
|
|
7
|
+
{ name: 'red', hex: '#FF3B30' },
|
|
8
|
+
{ name: 'orange', hex: '#FF9500' },
|
|
9
|
+
{ name: 'yellow', hex: '#FFCC00' },
|
|
10
|
+
{ name: 'green', hex: '#34C759' },
|
|
11
|
+
{ name: 'aqua', hex: '#5AC8FA' },
|
|
12
|
+
{ name: 'blue', hex: '#007AFF' },
|
|
13
|
+
{ name: 'purple', hex: '#AF52DE' },
|
|
14
|
+
{ name: 'magenta', hex: '#FF2D55' }
|
|
15
|
+
];
|
|
16
|
+
let activeRange = $derived(hsl[activeColor]);
|
|
17
|
+
function selectColor(name) {
|
|
18
|
+
if (name === activeColor)
|
|
19
|
+
return;
|
|
20
|
+
haptic('selection');
|
|
21
|
+
activeColor = name;
|
|
22
|
+
}
|
|
23
|
+
function handleSliderChange(property, value) {
|
|
24
|
+
const updatedRange = {
|
|
25
|
+
...hsl[activeColor],
|
|
26
|
+
[property]: value
|
|
27
|
+
};
|
|
28
|
+
const updatedHsl = {
|
|
29
|
+
...hsl,
|
|
30
|
+
[activeColor]: updatedRange
|
|
31
|
+
};
|
|
32
|
+
onChange(updatedHsl);
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<div class="hsl-tool">
|
|
37
|
+
<div class="color-selector" role="radiogroup" aria-label={$_('adjustments.hsl.colors')}>
|
|
38
|
+
{#each COLOR_MAP as color (color.name)}
|
|
39
|
+
<button
|
|
40
|
+
role="radio"
|
|
41
|
+
aria-checked={activeColor === color.name}
|
|
42
|
+
aria-label={$_(`adjustments.hsl.${color.name}`)}
|
|
43
|
+
class="color-dot"
|
|
44
|
+
class:active={activeColor === color.name}
|
|
45
|
+
style:--dot-color={color.hex}
|
|
46
|
+
onclick={() => selectColor(color.name)}
|
|
47
|
+
>
|
|
48
|
+
<span class="dot-inner"></span>
|
|
49
|
+
</button>
|
|
50
|
+
{/each}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="slider-list">
|
|
54
|
+
<Slider
|
|
55
|
+
label={$_('adjustments.hsl.hue')}
|
|
56
|
+
value={activeRange.hue}
|
|
57
|
+
min={-180}
|
|
58
|
+
max={180}
|
|
59
|
+
bipolar={true}
|
|
60
|
+
suffix="°"
|
|
61
|
+
onInput={(v) => handleSliderChange('hue', v)}
|
|
62
|
+
/>
|
|
63
|
+
<Slider
|
|
64
|
+
label={$_('adjustments.hsl.saturation')}
|
|
65
|
+
value={activeRange.saturation}
|
|
66
|
+
min={-100}
|
|
67
|
+
max={100}
|
|
68
|
+
bipolar={true}
|
|
69
|
+
onInput={(v) => handleSliderChange('saturation', v)}
|
|
70
|
+
/>
|
|
71
|
+
<Slider
|
|
72
|
+
label={$_('adjustments.hsl.luminance')}
|
|
73
|
+
value={activeRange.luminance}
|
|
74
|
+
min={-100}
|
|
75
|
+
max={100}
|
|
76
|
+
bipolar={true}
|
|
77
|
+
onInput={(v) => handleSliderChange('luminance', v)}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
.hsl-tool {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
gap: var(--tk-space-4);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.color-selector {
|
|
90
|
+
display: flex;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
gap: var(--tk-space-3);
|
|
93
|
+
padding: var(--tk-space-2) 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.color-dot {
|
|
97
|
+
position: relative;
|
|
98
|
+
width: 28px;
|
|
99
|
+
height: 28px;
|
|
100
|
+
border: none;
|
|
101
|
+
background: transparent;
|
|
102
|
+
border-radius: var(--tk-radius-full);
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
display: grid;
|
|
105
|
+
place-items: center;
|
|
106
|
+
padding: 0;
|
|
107
|
+
-webkit-tap-highlight-color: transparent;
|
|
108
|
+
transition: transform var(--tk-dur-quick) var(--tk-ease-spring);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.color-dot:hover {
|
|
112
|
+
transform: scale(1.1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.color-dot:active {
|
|
116
|
+
transform: scale(0.95);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.color-dot.active {
|
|
120
|
+
box-shadow: 0 0 0 2px var(--tk-bg-base), 0 0 0 4px var(--dot-color);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.dot-inner {
|
|
124
|
+
width: 18px;
|
|
125
|
+
height: 18px;
|
|
126
|
+
border-radius: var(--tk-radius-full);
|
|
127
|
+
background: var(--dot-color);
|
|
128
|
+
box-shadow: var(--tk-shadow-xs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.slider-list {
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
gap: var(--tk-space-3);
|
|
135
|
+
}</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HSLAdjustment } from '../types';
|
|
2
|
+
interface Props {
|
|
3
|
+
hsl: HSLAdjustment;
|
|
4
|
+
onChange: (hsl: HSLAdjustment) => void;
|
|
5
|
+
}
|
|
6
|
+
declare const HSLTool: import("svelte").Component<Props, {}, "">;
|
|
7
|
+
type HSLTool = ReturnType<typeof HSLTool>;
|
|
8
|
+
export default HSLTool;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">import '../i18n';
|
|
2
2
|
import '../styles/tokens.css';
|
|
3
3
|
import { _ } from 'svelte-i18n';
|
|
4
|
-
import { Redo2, Undo2, RotateCcw, ImagePlus, Check, Sparkles, Download } from 'lucide-svelte';
|
|
4
|
+
import { Redo2, Undo2, RotateCcw, ImagePlus, Check, Sparkles, Download, LoaderCircle } from 'lucide-svelte';
|
|
5
5
|
import { createEditorState, loadImageFromFile, loadImageFromUrl, applyImageToState, setMode, applyCrop, applyTransformUpdate, applyAdjustmentsUpdate, applyFilter, setBlurAreas, setStampAreas, setAnnotations, setViewport, resetState, handleZoom as coreHandleZoom, saveToHistory as coreSaveToHistory, handleUndo as coreHandleUndo, handleRedo as coreHandleRedo, canUndo, canRedo, getKeyboardAction, applyKeyboardAction, exportImage, downloadExportedImage, getDroppedFile, getInputFile, handleDragOver } from '../utils/editor-core';
|
|
6
6
|
import { calculateFitScale } from '../utils/canvas';
|
|
7
7
|
import { haptic } from '../utils/haptics';
|
|
@@ -37,6 +37,7 @@ let lastInitialImage = $state(undefined);
|
|
|
37
37
|
let stageWidth = $state(undefined);
|
|
38
38
|
let stageHeight = $state(undefined);
|
|
39
39
|
let isHovering = $state(false);
|
|
40
|
+
let isApplying = $state(false);
|
|
40
41
|
// Load initial image when provided
|
|
41
42
|
$effect(() => {
|
|
42
43
|
if (initialImage && initialImage !== lastInitialImage) {
|
|
@@ -153,6 +154,14 @@ function handleAdjustmentsChange(adjustments) {
|
|
|
153
154
|
adjustmentThrottleTimer = null;
|
|
154
155
|
}, 300);
|
|
155
156
|
}
|
|
157
|
+
function handleCurveChange(curve) {
|
|
158
|
+
state = applyAdjustmentsUpdate(state, { toneCurve: curve });
|
|
159
|
+
state = coreSaveToHistory(state);
|
|
160
|
+
}
|
|
161
|
+
function handleHSLChange(hsl) {
|
|
162
|
+
state = applyAdjustmentsUpdate(state, { hsl });
|
|
163
|
+
state = coreSaveToHistory(state);
|
|
164
|
+
}
|
|
156
165
|
function handleFilterApply(adjustments) {
|
|
157
166
|
state = applyFilter(state, adjustments);
|
|
158
167
|
state = coreSaveToHistory(state);
|
|
@@ -177,12 +186,18 @@ async function handleExport() {
|
|
|
177
186
|
onExport(result.dataUrl);
|
|
178
187
|
}
|
|
179
188
|
async function handleComplete() {
|
|
180
|
-
if (!onComplete)
|
|
189
|
+
if (!onComplete || isApplying)
|
|
181
190
|
return;
|
|
191
|
+
isApplying = true;
|
|
182
192
|
haptic('success');
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
try {
|
|
194
|
+
const result = await exportImage(state);
|
|
195
|
+
if (result)
|
|
196
|
+
onComplete(result.dataUrl, { blob: result.blob, width: result.width, height: result.height });
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
isApplying = false;
|
|
200
|
+
}
|
|
186
201
|
}
|
|
187
202
|
function handleCancel() {
|
|
188
203
|
haptic('light');
|
|
@@ -281,10 +296,14 @@ let hasImage = $derived(!!state.imageData.original);
|
|
|
281
296
|
</div>
|
|
282
297
|
|
|
283
298
|
<div class="topbar-right">
|
|
284
|
-
{#if hasImage}
|
|
299
|
+
{#if hasImage && state.mode !== 'crop'}
|
|
285
300
|
{#if !isStandalone}
|
|
286
|
-
<button type="button" class="primary-link" onclick={handleComplete}>
|
|
287
|
-
|
|
301
|
+
<button type="button" class="primary-link" onclick={handleComplete} disabled={isApplying}>
|
|
302
|
+
{#if isApplying}
|
|
303
|
+
<LoaderCircle size={16} class="spin" />
|
|
304
|
+
{:else}
|
|
305
|
+
<Check size={16} strokeWidth={2.6} />
|
|
306
|
+
{/if}
|
|
288
307
|
<span>{$_('editor.apply')}</span>
|
|
289
308
|
</button>
|
|
290
309
|
{:else}
|
|
@@ -347,7 +366,7 @@ let hasImage = $derived(!!state.imageData.original);
|
|
|
347
366
|
class="canvas-stage"
|
|
348
367
|
onwheel={(e) => {
|
|
349
368
|
e.preventDefault();
|
|
350
|
-
handleZoom(-e.deltaY * 0.
|
|
369
|
+
handleZoom(-e.deltaY * 0.003, e.clientX, e.clientY);
|
|
351
370
|
}}
|
|
352
371
|
>
|
|
353
372
|
<Canvas
|
|
@@ -426,6 +445,8 @@ let hasImage = $derived(!!state.imageData.original);
|
|
|
426
445
|
<AdjustTool
|
|
427
446
|
adjustments={state.adjustments}
|
|
428
447
|
onChange={handleAdjustmentsChange}
|
|
448
|
+
onCurveChange={handleCurveChange}
|
|
449
|
+
onHSLChange={handleHSLChange}
|
|
429
450
|
onClose={() => (state.mode = null)}
|
|
430
451
|
/>
|
|
431
452
|
{:else if state.mode === 'filter'}
|
|
@@ -600,6 +621,17 @@ let hasImage = $derived(!!state.imageData.original);
|
|
|
600
621
|
.primary-link:active {
|
|
601
622
|
transform: translateY(0);
|
|
602
623
|
}
|
|
624
|
+
.primary-link:disabled {
|
|
625
|
+
opacity: 0.6;
|
|
626
|
+
pointer-events: none;
|
|
627
|
+
}
|
|
628
|
+
.primary-link :global(.spin) {
|
|
629
|
+
animation: spin 1s linear infinite;
|
|
630
|
+
}
|
|
631
|
+
@keyframes spin {
|
|
632
|
+
from { transform: rotate(0deg); }
|
|
633
|
+
to { transform: rotate(360deg); }
|
|
634
|
+
}
|
|
603
635
|
|
|
604
636
|
/* ──────────────────────────────────────────────
|
|
605
637
|
* Stage — fills everything under the topbar. The dock floats above it.
|