tokimeki-image-editor 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,76 @@
1
- <script lang="ts">import { onMount } from 'svelte';
2
- import { drawImage, preloadStampImage } from '../utils/canvas';
3
- let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], onZoom } = $props();
1
+ <script lang="ts">import { onMount, onDestroy } from 'svelte';
2
+ import { drawImage, preloadStampImage, applyStamps } from '../utils/canvas';
3
+ import { initWebGPUCanvas, uploadImageToGPU, renderWithAdjustments, cleanupWebGPU, isWebGPUInitialized } from '../utils/webgpu-render';
4
+ let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], onZoom, onViewportChange } = $props();
5
+ // Constants
6
+ const PAN_OVERFLOW_MARGIN = 0.2; // Allow 20% overflow when panning
7
+ // State
4
8
  let canvasElement = $state(null);
9
+ let overlayCanvasElement = $state(null);
10
+ let isInitializing = $state(true); // Prevent 2D rendering before WebGPU check
11
+ let useWebGPU = $state(false);
12
+ let webgpuReady = $state(false);
13
+ let currentImage = null;
14
+ // 2D Canvas fallback state
5
15
  let isPanning = $state(false);
6
16
  let lastPanPosition = $state({ x: 0, y: 0 });
7
- let imageLoadCounter = $state(0); // Trigger redraw when images load
17
+ let imageLoadCounter = $state(0); // Incremented to trigger re-render when stamp images load
8
18
  let initialPinchDistance = $state(0);
9
19
  let initialZoom = $state(1);
10
20
  let renderRequested = $state(false);
11
21
  let pendingRenderFrame = null;
12
- onMount(() => {
13
- if (canvasElement) {
14
- canvas = canvasElement;
15
- // Add touch event listeners with passive: false to allow preventDefault
16
- canvasElement.addEventListener('touchstart', handleTouchStart, { passive: false });
17
- canvasElement.addEventListener('touchmove', handleTouchMove, { passive: false });
18
- canvasElement.addEventListener('touchend', handleTouchEnd, { passive: false });
22
+ let needsAnotherRender = $state(false);
23
+ // Helper functions
24
+ function ensureCanvasSize(canvas, w, h) {
25
+ if (canvas.width !== w || canvas.height !== h) {
26
+ canvas.width = w;
27
+ canvas.height = h;
19
28
  }
29
+ }
30
+ function calculatePanOffset(deltaX, deltaY) {
31
+ if (!image || !canvasElement)
32
+ return null;
33
+ const imgWidth = cropArea ? cropArea.width : image.width;
34
+ const imgHeight = cropArea ? cropArea.height : image.height;
35
+ const totalScale = viewport.scale * viewport.zoom;
36
+ const scaledWidth = imgWidth * totalScale;
37
+ const scaledHeight = imgHeight * totalScale;
38
+ const maxOffsetX = (scaledWidth / 2) - (canvasElement.width / 2) + (canvasElement.width * PAN_OVERFLOW_MARGIN);
39
+ const maxOffsetY = (scaledHeight / 2) - (canvasElement.height / 2) + (canvasElement.height * PAN_OVERFLOW_MARGIN);
40
+ const newOffsetX = viewport.offsetX + deltaX;
41
+ const newOffsetY = viewport.offsetY + deltaY;
42
+ const clampedOffsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, newOffsetX));
43
+ const clampedOffsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, newOffsetY));
44
+ return { clampedOffsetX, clampedOffsetY };
45
+ }
46
+ onMount(async () => {
47
+ if (!canvasElement)
48
+ return;
49
+ canvas = canvasElement;
50
+ // Try WebGPU first
51
+ if (navigator.gpu) {
52
+ console.log('Attempting WebGPU initialization...');
53
+ const success = await initWebGPUCanvas(canvasElement);
54
+ if (success) {
55
+ useWebGPU = true;
56
+ webgpuReady = true;
57
+ console.log('✅ Using WebGPU rendering');
58
+ }
59
+ else {
60
+ console.log('⚠️ WebGPU not available, using 2D Canvas fallback');
61
+ useWebGPU = false;
62
+ }
63
+ }
64
+ else {
65
+ console.log('⚠️ WebGPU not available, using 2D Canvas fallback');
66
+ useWebGPU = false;
67
+ }
68
+ isInitializing = false;
69
+ // Setup touch event listeners (common for both modes)
70
+ canvasElement.addEventListener('touchstart', handleTouchStart, { passive: false });
71
+ canvasElement.addEventListener('touchmove', handleTouchMove, { passive: false });
72
+ canvasElement.addEventListener('touchend', handleTouchEnd, { passive: false });
20
73
  return () => {
21
- // Cleanup event listeners
22
74
  if (canvasElement) {
23
75
  canvasElement.removeEventListener('touchstart', handleTouchStart);
24
76
  canvasElement.removeEventListener('touchmove', handleTouchMove);
@@ -26,47 +78,56 @@ onMount(() => {
26
78
  }
27
79
  };
28
80
  });
29
- // Request a render using requestAnimationFrame
30
- function requestRender() {
31
- if (renderRequested)
32
- return;
33
- renderRequested = true;
34
- if (pendingRenderFrame !== null) {
35
- cancelAnimationFrame(pendingRenderFrame);
81
+ onDestroy(() => {
82
+ if (useWebGPU) {
83
+ cleanupWebGPU();
36
84
  }
37
- pendingRenderFrame = requestAnimationFrame(() => {
38
- performRender();
39
- renderRequested = false;
40
- pendingRenderFrame = null;
41
- });
42
- }
43
- // Perform the actual render
44
- function performRender() {
45
- if (!canvasElement || !image)
46
- return;
47
- canvasElement.width = width;
48
- canvasElement.height = height;
49
- drawImage(canvasElement, image, viewport, transform, adjustments, cropArea, blurAreas, stampAreas);
50
- }
51
- // Preload stamp images
85
+ });
86
+ // WebGPU: Upload image when it changes
52
87
  $effect(() => {
53
- if (!stampAreas)
54
- return;
55
- stampAreas.forEach(stamp => {
56
- if (stamp.stampType === 'image' || stamp.stampType === 'svg') {
57
- preloadStampImage(stamp.stampContent).then(() => {
58
- // Trigger redraw when image loads
59
- imageLoadCounter++;
60
- }).catch(console.error);
61
- }
62
- });
88
+ if (useWebGPU && webgpuReady && image && image !== currentImage) {
89
+ currentImage = image;
90
+ uploadImageToGPU(image).then((success) => {
91
+ if (success) {
92
+ renderWebGPU();
93
+ }
94
+ });
95
+ }
63
96
  });
64
- // Draw canvas - use requestAnimationFrame for optimal performance
97
+ // WebGPU: Render when parameters change
65
98
  $effect(() => {
66
- if (canvasElement && image) {
99
+ if (useWebGPU && webgpuReady && currentImage && canvasElement) {
100
+ renderWebGPU();
101
+ }
102
+ // Track object properties explicitly for reactivity
103
+ adjustments.brightness;
104
+ adjustments.contrast;
105
+ adjustments.saturation;
106
+ viewport.offsetX;
107
+ viewport.offsetY;
108
+ viewport.zoom;
109
+ viewport.scale;
110
+ transform.rotation;
111
+ transform.flipHorizontal;
112
+ transform.flipVertical;
113
+ transform.scale;
114
+ // Track cropArea object itself and its properties
115
+ cropArea;
116
+ cropArea?.x;
117
+ cropArea?.y;
118
+ cropArea?.width;
119
+ cropArea?.height;
120
+ blurAreas;
121
+ stampAreas;
122
+ width;
123
+ height;
124
+ });
125
+ // 2D Canvas: Render when parameters change
126
+ $effect(() => {
127
+ if (!isInitializing && !useWebGPU && canvasElement && image) {
67
128
  requestRender();
68
129
  }
69
- // Include all dependencies
130
+ // Dependencies
70
131
  width;
71
132
  height;
72
133
  viewport;
@@ -77,8 +138,81 @@ $effect(() => {
77
138
  stampAreas;
78
139
  imageLoadCounter;
79
140
  });
141
+ // Preload stamp images
142
+ $effect(() => {
143
+ if (stampAreas) {
144
+ stampAreas.forEach(stamp => {
145
+ if (stamp.stampType === 'image' || stamp.stampType === 'svg') {
146
+ preloadStampImage(stamp.stampContent).then(() => {
147
+ if (!useWebGPU) {
148
+ imageLoadCounter++;
149
+ }
150
+ else {
151
+ // Trigger re-render for WebGPU mode
152
+ renderWebGPU();
153
+ }
154
+ }).catch(console.error);
155
+ }
156
+ });
157
+ }
158
+ });
159
+ function renderWebGPU() {
160
+ if (!canvasElement || !webgpuReady || !currentImage)
161
+ return;
162
+ ensureCanvasSize(canvasElement, width, height);
163
+ renderWithAdjustments(adjustments, viewport, transform, width, height, currentImage.width, currentImage.height, cropArea, blurAreas);
164
+ // Render stamps on overlay canvas
165
+ if (overlayCanvasElement && stampAreas.length > 0) {
166
+ ensureCanvasSize(overlayCanvasElement, width, height);
167
+ // Clear overlay canvas
168
+ const ctx = overlayCanvasElement.getContext('2d');
169
+ if (ctx) {
170
+ ctx.clearRect(0, 0, width, height);
171
+ applyStamps(overlayCanvasElement, currentImage, viewport, stampAreas, cropArea);
172
+ }
173
+ }
174
+ else if (overlayCanvasElement) {
175
+ // Clear overlay if no stamps
176
+ const ctx = overlayCanvasElement.getContext('2d');
177
+ if (ctx) {
178
+ ctx.clearRect(0, 0, width, height);
179
+ }
180
+ }
181
+ }
182
+ // === 2D Canvas Fallback Implementation ===
183
+ function requestRender() {
184
+ if (renderRequested) {
185
+ needsAnotherRender = true;
186
+ return;
187
+ }
188
+ renderRequested = true;
189
+ needsAnotherRender = false;
190
+ if (pendingRenderFrame !== null) {
191
+ cancelAnimationFrame(pendingRenderFrame);
192
+ }
193
+ pendingRenderFrame = requestAnimationFrame(() => {
194
+ performRender()
195
+ .catch(error => {
196
+ console.error('Render error:', error);
197
+ })
198
+ .finally(() => {
199
+ renderRequested = false;
200
+ pendingRenderFrame = null;
201
+ if (needsAnotherRender) {
202
+ needsAnotherRender = false;
203
+ requestRender();
204
+ }
205
+ });
206
+ });
207
+ }
208
+ async function performRender() {
209
+ if (!canvasElement || !image)
210
+ return;
211
+ canvasElement.width = width;
212
+ canvasElement.height = height;
213
+ await drawImage(canvasElement, image, viewport, transform, adjustments, cropArea, blurAreas, stampAreas);
214
+ }
80
215
  function handleMouseDown(e) {
81
- // Left mouse button (0) or Middle mouse button (1) for panning
82
216
  if (e.button === 0 || e.button === 1) {
83
217
  isPanning = true;
84
218
  lastPanPosition = { x: e.clientX, y: e.clientY };
@@ -86,24 +220,13 @@ function handleMouseDown(e) {
86
220
  }
87
221
  }
88
222
  function handleMouseMove(e) {
89
- if (isPanning && image && canvasElement) {
223
+ if (isPanning) {
90
224
  const deltaX = e.clientX - lastPanPosition.x;
91
225
  const deltaY = e.clientY - lastPanPosition.y;
92
- // Calculate actual image dimensions after crop and scale
93
- const imgWidth = cropArea ? cropArea.width : image.width;
94
- const imgHeight = cropArea ? cropArea.height : image.height;
95
- const totalScale = viewport.scale * viewport.zoom;
96
- const scaledWidth = imgWidth * totalScale;
97
- const scaledHeight = imgHeight * totalScale;
98
- // Allow 20% overflow outside canvas
99
- const overflowMargin = 0.2;
100
- const maxOffsetX = (scaledWidth / 2) - (canvasElement.width / 2) + (canvasElement.width * overflowMargin);
101
- const maxOffsetY = (scaledHeight / 2) - (canvasElement.height / 2) + (canvasElement.height * overflowMargin);
102
- // Apply limits
103
- const newOffsetX = viewport.offsetX + deltaX;
104
- const newOffsetY = viewport.offsetY + deltaY;
105
- viewport.offsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, newOffsetX));
106
- viewport.offsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, newOffsetY));
226
+ const result = calculatePanOffset(deltaX, deltaY);
227
+ if (result && onViewportChange) {
228
+ onViewportChange({ offsetX: result.clampedOffsetX, offsetY: result.clampedOffsetY });
229
+ }
107
230
  lastPanPosition = { x: e.clientX, y: e.clientY };
108
231
  e.preventDefault();
109
232
  }
@@ -113,43 +236,28 @@ function handleMouseUp() {
113
236
  }
114
237
  function handleTouchStart(e) {
115
238
  if (e.touches.length === 1) {
116
- // Single finger panning
117
239
  isPanning = true;
118
240
  lastPanPosition = { x: e.touches[0].clientX, y: e.touches[0].clientY };
119
241
  e.preventDefault();
120
242
  }
121
243
  else if (e.touches.length === 2) {
122
- // Pinch zoom - stop panning
123
244
  isPanning = false;
124
245
  e.preventDefault();
125
246
  }
126
247
  }
127
248
  function handleTouchMove(e) {
128
- if (e.touches.length === 1 && isPanning && image && canvasElement) {
129
- // Single finger panning
249
+ if (e.touches.length === 1 && isPanning) {
130
250
  e.preventDefault();
131
251
  const touch = e.touches[0];
132
252
  const deltaX = touch.clientX - lastPanPosition.x;
133
253
  const deltaY = touch.clientY - lastPanPosition.y;
134
- // Calculate actual image dimensions after crop and scale
135
- const imgWidth = cropArea ? cropArea.width : image.width;
136
- const imgHeight = cropArea ? cropArea.height : image.height;
137
- const totalScale = viewport.scale * viewport.zoom;
138
- const scaledWidth = imgWidth * totalScale;
139
- const scaledHeight = imgHeight * totalScale;
140
- // Allow 20% overflow outside canvas
141
- const overflowMargin = 0.2;
142
- const maxOffsetX = (scaledWidth / 2) - (canvasElement.width / 2) + (canvasElement.width * overflowMargin);
143
- const maxOffsetY = (scaledHeight / 2) - (canvasElement.height / 2) + (canvasElement.height * overflowMargin);
144
- // Apply limits
145
- const newOffsetX = viewport.offsetX + deltaX;
146
- const newOffsetY = viewport.offsetY + deltaY;
147
- viewport.offsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, newOffsetX));
148
- viewport.offsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, newOffsetY));
254
+ const result = calculatePanOffset(deltaX, deltaY);
255
+ if (result && onViewportChange) {
256
+ onViewportChange({ offsetX: result.clampedOffsetX, offsetY: result.clampedOffsetY });
257
+ }
149
258
  lastPanPosition = { x: touch.clientX, y: touch.clientY };
150
259
  }
151
260
  else if (e.touches.length === 2) {
152
- // Pinch zoom
153
261
  e.preventDefault();
154
262
  const touch1 = e.touches[0];
155
263
  const touch2 = e.touches[1];
@@ -176,39 +284,90 @@ function handleTouchEnd(e) {
176
284
  initialPinchDistance = 0;
177
285
  }
178
286
  else if (e.touches.length === 1) {
179
- // Switched from pinch to pan
180
287
  initialPinchDistance = 0;
181
288
  isPanning = true;
182
289
  lastPanPosition = { x: e.touches[0].clientX, y: e.touches[0].clientY };
183
290
  }
184
291
  }
185
- </script>
186
-
187
- <svelte:window
188
- onmousemove={handleMouseMove}
189
- onmouseup={handleMouseUp}
190
- />
191
-
192
- <canvas
193
- bind:this={canvasElement}
194
- width={width}
195
- height={height}
196
- class="editor-canvas"
197
- class:panning={isPanning}
198
- style="max-width: 100%; max-height: {height}px;"
199
- onmousedown={handleMouseDown}
200
- ></canvas>
201
-
202
- <style>
203
- .editor-canvas {
204
- display: block;
205
- background: #000;
206
- cursor: grab;
207
- touch-action: none;
208
- user-select: none;
209
- -webkit-user-select: none;
210
- }
211
-
212
- .editor-canvas.panning {
213
- cursor: grabbing;
214
- }</style>
292
+ </script>
293
+
294
+ <svelte:window
295
+ onmousemove={handleMouseMove}
296
+ onmouseup={handleMouseUp}
297
+ />
298
+
299
+ <div class="canvas-container">
300
+ <canvas
301
+ bind:this={canvasElement}
302
+ width={width}
303
+ height={height}
304
+ class="editor-canvas"
305
+ class:panning={isPanning}
306
+ style="max-width: 100%; max-height: {height}px;"
307
+ onmousedown={handleMouseDown}
308
+ ></canvas>
309
+
310
+ {#if useWebGPU}
311
+ <canvas
312
+ bind:this={overlayCanvasElement}
313
+ width={width}
314
+ height={height}
315
+ class="overlay-canvas"
316
+ style="max-width: 100%; max-height: {height}px; pointer-events: none;"
317
+ ></canvas>
318
+ {/if}
319
+
320
+ {#if useWebGPU && webgpuReady}
321
+ <div class="gpu-indicator">
322
+ <span class="gpu-badge">WebGPU</span>
323
+ </div>
324
+ {/if}
325
+ </div>
326
+
327
+ <style>
328
+ .canvas-container {
329
+ position: relative;
330
+ display: inline-block;
331
+ }
332
+
333
+ .editor-canvas {
334
+ display: block;
335
+ background: #000;
336
+ cursor: grab;
337
+ touch-action: none;
338
+ user-select: none;
339
+ -webkit-user-select: none;
340
+ }
341
+
342
+ .editor-canvas.panning {
343
+ cursor: grabbing;
344
+ }
345
+
346
+ .overlay-canvas {
347
+ position: absolute;
348
+ top: 0;
349
+ left: 0;
350
+ display: block;
351
+ pointer-events: none;
352
+ }
353
+
354
+ .gpu-indicator {
355
+ position: absolute;
356
+ top: 10px;
357
+ right: 10px;
358
+ pointer-events: none;
359
+ z-index: 10;
360
+ }
361
+
362
+ .gpu-badge {
363
+ display: inline-block;
364
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
365
+ color: white;
366
+ padding: 4px 12px;
367
+ border-radius: 12px;
368
+ font-size: 11px;
369
+ font-weight: 600;
370
+ letter-spacing: 0.5px;
371
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
372
+ text-transform: uppercase;
373
+ }</style>
@@ -11,6 +11,7 @@ interface Props {
11
11
  blurAreas?: BlurArea[];
12
12
  stampAreas?: StampArea[];
13
13
  onZoom?: (delta: number, centerX?: number, centerY?: number) => void;
14
+ onViewportChange?: (viewportUpdate: Partial<Viewport>) => void;
14
15
  }
15
16
  declare const Canvas: import("svelte").Component<Props, {}, "canvas">;
16
17
  type Canvas = ReturnType<typeof Canvas>;
@@ -406,6 +406,12 @@ function handlePinchZoomEnd() {
406
406
  }
407
407
  // Unified touch handlers that delegate based on finger count
408
408
  function handleContainerTouchStartUnified(event) {
409
+ // Check if touch is on a button or interactive element
410
+ const target = event.target;
411
+ if (target.closest('button') || target.closest('.crop-top-controls') || target.closest('.crop-controls')) {
412
+ // Let the button handle the event
413
+ return;
414
+ }
409
415
  // Two fingers = pinch zoom crop area
410
416
  if (event.touches.length === 2) {
411
417
  handlePinchZoomStart(event);
@@ -742,6 +748,7 @@ function toggleFlipVertical() {
742
748
  flex-direction: column;
743
749
  gap: 0.75rem;
744
750
  z-index: 20;
751
+ pointer-events: auto;
745
752
  }
746
753
 
747
754
  @media (max-width: 767px) {
@@ -883,6 +890,7 @@ function toggleFlipVertical() {
883
890
  display: flex;
884
891
  gap: 0.5rem;
885
892
  z-index: 20;
893
+ pointer-events: auto;
886
894
  }
887
895
 
888
896
  @media (max-width: 767px) {
@@ -8,9 +8,13 @@ function handleQualityChange(event) {
8
8
  const value = parseFloat(event.target.value);
9
9
  onChange({ quality: value });
10
10
  }
11
+ // Prevent wheel events from propagating to canvas zoom handler
12
+ function handleWheel(e) {
13
+ e.stopPropagation();
14
+ }
11
15
  </script>
12
16
 
13
- <div class="export-tool">
17
+ <div class="export-tool" onwheel={handleWheel}>
14
18
  <div class="tool-header">
15
19
  <h3>{$_('editor.export')}</h3>
16
20
  <button class="close-btn" onclick={onClose}>✕</button>