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.
Files changed (47) hide show
  1. package/dist/components/AdjustTool.svelte +128 -27
  2. package/dist/components/AdjustTool.svelte.d.ts +3 -1
  3. package/dist/components/AnnotationTool.svelte +2 -2
  4. package/dist/components/Canvas.svelte +22 -4
  5. package/dist/components/HSLTool.svelte +135 -0
  6. package/dist/components/HSLTool.svelte.d.ts +8 -0
  7. package/dist/components/ImageEditor.svelte +41 -9
  8. package/dist/components/ToneCurveTool.svelte +435 -0
  9. package/dist/components/ToneCurveTool.svelte.d.ts +8 -0
  10. package/dist/i18n/locales/en.json +27 -1
  11. package/dist/i18n/locales/ja.json +27 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/shaders/blur.d.ts +1 -1
  14. package/dist/shaders/blur.js +66 -59
  15. package/dist/shaders/denoise.d.ts +7 -0
  16. package/dist/shaders/denoise.js +89 -0
  17. package/dist/shaders/grain.d.ts +1 -1
  18. package/dist/shaders/grain.js +214 -225
  19. package/dist/shaders/image-editor.d.ts +1 -1
  20. package/dist/shaders/image-editor.js +107 -9
  21. package/dist/shaders/sharpen.d.ts +14 -0
  22. package/dist/shaders/sharpen.js +95 -0
  23. package/dist/types.d.ts +30 -0
  24. package/dist/utils/__tests__/adjustments.test.d.ts +1 -0
  25. package/dist/utils/__tests__/adjustments.test.js +110 -0
  26. package/dist/utils/__tests__/editor-core.test.d.ts +1 -0
  27. package/dist/utils/__tests__/editor-core.test.js +78 -0
  28. package/dist/utils/__tests__/filters.test.d.ts +1 -0
  29. package/dist/utils/__tests__/filters.test.js +58 -0
  30. package/dist/utils/__tests__/history.test.d.ts +1 -0
  31. package/dist/utils/__tests__/history.test.js +100 -0
  32. package/dist/utils/__tests__/lod.test.d.ts +1 -0
  33. package/dist/utils/__tests__/lod.test.js +95 -0
  34. package/dist/utils/__tests__/viewport.test.d.ts +1 -0
  35. package/dist/utils/__tests__/viewport.test.js +45 -0
  36. package/dist/utils/adjustments.d.ts +31 -1
  37. package/dist/utils/adjustments.js +168 -2
  38. package/dist/utils/editor-core.js +2 -2
  39. package/dist/utils/editor-interaction.d.ts +3 -1
  40. package/dist/utils/editor-interaction.js +19 -11
  41. package/dist/utils/filters.d.ts +2 -1
  42. package/dist/utils/filters.js +272 -5
  43. package/dist/utils/history.d.ts +4 -2
  44. package/dist/utils/history.js +20 -2
  45. package/dist/utils/webgpu-render.d.ts +5 -1
  46. package/dist/utils/webgpu-render.js +453 -20
  47. 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
- : effectControls);
53
- // Quick visual feedback: show how many adjustments are active
54
- let activeCount = $derived(Object.values(adjustments).filter((v) => v !== 0).length);
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 class="group-tabs" role="tablist">
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
- {#each currentControls as control (control.key)}
101
- {@const Icon = control.icon}
102
- <div class="control-row">
103
- <div class="control-icon">
104
- <Icon size={16} strokeWidth={1.8} />
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
- <div class="control-slider">
107
- <Slider
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(5, interactionState.initialPinchZoom * scale));
745
- const delta = newZoom - viewport.zoom;
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
- requestRender();
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 - viewport.zoom;
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 - viewport.zoom;
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
- const result = await exportImage(state);
184
- if (result)
185
- onComplete(result.dataUrl, { blob: result.blob, width: result.width, height: result.height });
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
- <Check size={16} strokeWidth={2.6} />
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.001, e.clientX, e.clientY);
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.