tokimeki-image-editor 0.1.1 → 0.1.3

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 (39) hide show
  1. package/dist/components/AdjustTool.svelte +317 -0
  2. package/dist/components/AdjustTool.svelte.d.ts +9 -0
  3. package/dist/components/BlurTool.svelte +613 -0
  4. package/dist/components/BlurTool.svelte.d.ts +15 -0
  5. package/dist/components/Canvas.svelte +214 -0
  6. package/dist/components/Canvas.svelte.d.ts +17 -0
  7. package/dist/components/CropTool.svelte +942 -0
  8. package/dist/components/CropTool.svelte.d.ts +14 -0
  9. package/dist/components/ExportTool.svelte +191 -0
  10. package/dist/components/ExportTool.svelte.d.ts +10 -0
  11. package/dist/components/FilterTool.svelte +492 -0
  12. package/dist/components/FilterTool.svelte.d.ts +12 -0
  13. package/dist/components/ImageEditor.svelte +735 -0
  14. package/dist/components/ImageEditor.svelte.d.ts +12 -0
  15. package/dist/components/RotateTool.svelte +157 -0
  16. package/dist/components/RotateTool.svelte.d.ts +9 -0
  17. package/dist/components/StampTool.svelte +678 -0
  18. package/dist/components/StampTool.svelte.d.ts +15 -0
  19. package/dist/components/Toolbar.svelte +136 -0
  20. package/dist/components/Toolbar.svelte.d.ts +10 -0
  21. package/dist/config/stamps.d.ts +2 -0
  22. package/dist/config/stamps.js +22 -0
  23. package/dist/i18n/index.d.ts +1 -0
  24. package/dist/i18n/index.js +9 -0
  25. package/dist/i18n/locales/en.json +68 -0
  26. package/dist/i18n/locales/ja.json +68 -0
  27. package/dist/index.d.ts +4 -0
  28. package/dist/index.js +5 -0
  29. package/dist/types.d.ts +97 -0
  30. package/dist/types.js +1 -0
  31. package/dist/utils/adjustments.d.ts +26 -0
  32. package/dist/utils/adjustments.js +525 -0
  33. package/dist/utils/canvas.d.ts +30 -0
  34. package/dist/utils/canvas.js +293 -0
  35. package/dist/utils/filters.d.ts +18 -0
  36. package/dist/utils/filters.js +114 -0
  37. package/dist/utils/history.d.ts +15 -0
  38. package/dist/utils/history.js +67 -0
  39. package/package.json +1 -1
@@ -0,0 +1,942 @@
1
+ <script lang="ts">import { onMount } from 'svelte';
2
+ import { _ } from 'svelte-i18n';
3
+ import { RotateCw, RotateCcw, FlipHorizontal, FlipVertical } from 'lucide-svelte';
4
+ import { screenToImageCoords, imageToCanvasCoords } from '../utils/canvas';
5
+ let { canvas, image, viewport, transform, onApply, onCancel, onViewportChange, onTransformChange } = $props();
6
+ let containerElement = $state(null);
7
+ // Crop area in image coordinates
8
+ let cropArea = $state({
9
+ x: 0,
10
+ y: 0,
11
+ width: 200,
12
+ height: 200
13
+ });
14
+ let isDragging = $state(false);
15
+ let isResizing = $state(false);
16
+ let dragStart = $state({ x: 0, y: 0 });
17
+ let resizeHandle = $state(null);
18
+ let initialCropArea = $state(null);
19
+ // Viewport panning state (for dragging outside crop area)
20
+ let isPanning = $state(false);
21
+ let lastPanPosition = $state({ x: 0, y: 0 });
22
+ // Touch pinch zoom state
23
+ let initialPinchDistance = $state(0);
24
+ let initialCropSize = $state(null);
25
+ // Helper to get coordinates from mouse or touch event
26
+ function getEventCoords(event) {
27
+ if ('touches' in event && event.touches.length > 0) {
28
+ return { clientX: event.touches[0].clientX, clientY: event.touches[0].clientY };
29
+ }
30
+ else if ('clientX' in event) {
31
+ return { clientX: event.clientX, clientY: event.clientY };
32
+ }
33
+ return { clientX: 0, clientY: 0 };
34
+ }
35
+ // Canvas coordinates for rendering
36
+ let canvasCoords = $derived.by(() => {
37
+ if (!canvas || !image)
38
+ return null;
39
+ const topLeft = imageToCanvasCoords(cropArea.x, cropArea.y, canvas, image, viewport);
40
+ const bottomRight = imageToCanvasCoords(cropArea.x + cropArea.width, cropArea.y + cropArea.height, canvas, image, viewport);
41
+ return {
42
+ x: topLeft.x,
43
+ y: topLeft.y,
44
+ width: bottomRight.x - topLeft.x,
45
+ height: bottomRight.y - topLeft.y
46
+ };
47
+ });
48
+ onMount(() => {
49
+ if (containerElement) {
50
+ // Add touch event listeners with passive: false to allow preventDefault
51
+ containerElement.addEventListener('touchstart', handleContainerTouchStartUnified, { passive: false });
52
+ containerElement.addEventListener('touchmove', handleContainerTouchMoveUnified, { passive: false });
53
+ containerElement.addEventListener('touchend', handleContainerTouchEndUnified, { passive: false });
54
+ }
55
+ return () => {
56
+ if (containerElement) {
57
+ containerElement.removeEventListener('touchstart', handleContainerTouchStartUnified);
58
+ containerElement.removeEventListener('touchmove', handleContainerTouchMoveUnified);
59
+ containerElement.removeEventListener('touchend', handleContainerTouchEndUnified);
60
+ }
61
+ };
62
+ });
63
+ $effect(() => {
64
+ if (image) {
65
+ // Initialize crop area to full image size
66
+ cropArea = {
67
+ x: 0,
68
+ y: 0,
69
+ width: image.width,
70
+ height: image.height
71
+ };
72
+ }
73
+ });
74
+ function handleMouseDown(event, handle) {
75
+ if (!canvas || !image)
76
+ return;
77
+ event.preventDefault();
78
+ event.stopPropagation();
79
+ const coords = getEventCoords(event);
80
+ dragStart = { x: coords.clientX, y: coords.clientY };
81
+ initialCropArea = { ...cropArea };
82
+ if (handle) {
83
+ isResizing = true;
84
+ resizeHandle = handle;
85
+ }
86
+ else {
87
+ isDragging = true;
88
+ }
89
+ }
90
+ // Get handle size based on device type (larger for touch)
91
+ function getHandleRadius(event) {
92
+ return 'touches' in event ? 20 : 6; // 20px for touch, 6px for mouse
93
+ }
94
+ function handleContainerMouseDown(event) {
95
+ if (!canvas || !canvasCoords)
96
+ return;
97
+ // Check if it's a mouse event with non-left button
98
+ if ('button' in event && event.button !== 0)
99
+ return;
100
+ // For touch events, only handle single touch for panning
101
+ if ('touches' in event && event.touches.length > 1)
102
+ return;
103
+ // Check if click is inside crop area
104
+ const rect = canvas.getBoundingClientRect();
105
+ const coords = getEventCoords(event);
106
+ const mouseX = coords.clientX - rect.left;
107
+ const mouseY = coords.clientY - rect.top;
108
+ const isInsideCropArea = mouseX >= canvasCoords.x &&
109
+ mouseX <= canvasCoords.x + canvasCoords.width &&
110
+ mouseY >= canvasCoords.y &&
111
+ mouseY <= canvasCoords.y + canvasCoords.height;
112
+ // If inside crop area, let SVG elements handle it
113
+ if (isInsideCropArea)
114
+ return;
115
+ // If outside crop area, start panning the viewport
116
+ event.preventDefault();
117
+ isPanning = true;
118
+ lastPanPosition = { x: coords.clientX, y: coords.clientY };
119
+ }
120
+ function handleMouseMove(event) {
121
+ if (!canvas || !image)
122
+ return;
123
+ const coords = getEventCoords(event);
124
+ // Handle viewport panning (when dragging outside crop area)
125
+ if (isPanning && onViewportChange) {
126
+ const deltaX = coords.clientX - lastPanPosition.x;
127
+ const deltaY = coords.clientY - lastPanPosition.y;
128
+ // Use original image dimensions (same as Canvas.svelte when not cropped)
129
+ const imgWidth = image.width;
130
+ const imgHeight = image.height;
131
+ const totalScale = viewport.scale * viewport.zoom;
132
+ const scaledWidth = imgWidth * totalScale;
133
+ const scaledHeight = imgHeight * totalScale;
134
+ // Allow 20% overflow outside canvas
135
+ const overflowMargin = 0.2;
136
+ const maxOffsetX = (scaledWidth / 2) - (canvas.width / 2) + (canvas.width * overflowMargin);
137
+ const maxOffsetY = (scaledHeight / 2) - (canvas.height / 2) + (canvas.height * overflowMargin);
138
+ // Apply limits
139
+ const newOffsetX = viewport.offsetX + deltaX;
140
+ const newOffsetY = viewport.offsetY + deltaY;
141
+ const clampedOffsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, newOffsetX));
142
+ const clampedOffsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, newOffsetY));
143
+ onViewportChange({
144
+ offsetX: clampedOffsetX,
145
+ offsetY: clampedOffsetY
146
+ });
147
+ lastPanPosition = { x: coords.clientX, y: coords.clientY };
148
+ event.preventDefault();
149
+ return;
150
+ }
151
+ // Handle crop area dragging and resizing
152
+ if (!initialCropArea)
153
+ return;
154
+ if (!isDragging && !isResizing)
155
+ return;
156
+ const deltaX = coords.clientX - dragStart.x;
157
+ const deltaY = coords.clientY - dragStart.y;
158
+ // Convert delta to image coordinates
159
+ const scale = viewport.scale * viewport.zoom;
160
+ const imageDeltaX = deltaX / scale;
161
+ const imageDeltaY = deltaY / scale;
162
+ if (isDragging) {
163
+ // Move crop area
164
+ cropArea.x = Math.max(0, Math.min(image.width - cropArea.width, initialCropArea.x + imageDeltaX));
165
+ cropArea.y = Math.max(0, Math.min(image.height - cropArea.height, initialCropArea.y + imageDeltaY));
166
+ }
167
+ else if (isResizing && resizeHandle) {
168
+ // Resize crop area
169
+ const minSize = 50;
170
+ if (resizeHandle.includes('w')) {
171
+ const newX = Math.max(0, Math.min(initialCropArea.x + initialCropArea.width - minSize, initialCropArea.x + imageDeltaX));
172
+ cropArea.width = initialCropArea.width + (initialCropArea.x - newX);
173
+ cropArea.x = newX;
174
+ }
175
+ if (resizeHandle.includes('e')) {
176
+ cropArea.width = Math.max(minSize, Math.min(image.width - initialCropArea.x, initialCropArea.width + imageDeltaX));
177
+ }
178
+ if (resizeHandle.includes('n')) {
179
+ const newY = Math.max(0, Math.min(initialCropArea.y + initialCropArea.height - minSize, initialCropArea.y + imageDeltaY));
180
+ cropArea.height = initialCropArea.height + (initialCropArea.y - newY);
181
+ cropArea.y = newY;
182
+ }
183
+ if (resizeHandle.includes('s')) {
184
+ cropArea.height = Math.max(minSize, Math.min(image.height - initialCropArea.y, initialCropArea.height + imageDeltaY));
185
+ }
186
+ }
187
+ }
188
+ function handleMouseUp() {
189
+ isDragging = false;
190
+ isResizing = false;
191
+ isPanning = false;
192
+ resizeHandle = null;
193
+ initialCropArea = null;
194
+ }
195
+ // These will be defined below after pinch zoom handlers are renamed
196
+ function apply() {
197
+ onApply(cropArea);
198
+ }
199
+ function setAspectRatio(ratio) {
200
+ if (!image)
201
+ return;
202
+ let newWidth;
203
+ let newHeight;
204
+ // Calculate crop size to fill as much of the image as possible
205
+ const imageAspectRatio = image.width / image.height;
206
+ if (imageAspectRatio > ratio) {
207
+ // Image is wider than the target ratio
208
+ // Use full height, calculate width
209
+ newHeight = image.height;
210
+ newWidth = newHeight * ratio;
211
+ }
212
+ else {
213
+ // Image is taller than the target ratio
214
+ // Use full width, calculate height
215
+ newWidth = image.width;
216
+ newHeight = newWidth / ratio;
217
+ }
218
+ // Center the crop area
219
+ cropArea = {
220
+ x: (image.width - newWidth) / 2,
221
+ y: (image.height - newHeight) / 2,
222
+ width: newWidth,
223
+ height: newHeight
224
+ };
225
+ }
226
+ function handleWheel(event) {
227
+ if (!image || !canvas || !canvasCoords)
228
+ return;
229
+ // Check if cursor is inside crop area
230
+ const rect = canvas.getBoundingClientRect();
231
+ const mouseX = event.clientX - rect.left;
232
+ const mouseY = event.clientY - rect.top;
233
+ const isInsideCropArea = mouseX >= canvasCoords.x &&
234
+ mouseX <= canvasCoords.x + canvasCoords.width &&
235
+ mouseY >= canvasCoords.y &&
236
+ mouseY <= canvasCoords.y + canvasCoords.height;
237
+ // If outside crop area, let the event bubble to ImageEditor for viewport zoom
238
+ if (!isInsideCropArea) {
239
+ return;
240
+ }
241
+ event.preventDefault();
242
+ event.stopPropagation();
243
+ // Calculate zoom delta
244
+ const delta = -event.deltaY * 0.001;
245
+ const zoomFactor = 1 + delta;
246
+ // Calculate new dimensions while maintaining aspect ratio
247
+ const currentAspectRatio = cropArea.width / cropArea.height;
248
+ const centerX = cropArea.x + cropArea.width / 2;
249
+ const centerY = cropArea.y + cropArea.height / 2;
250
+ let newWidth = cropArea.width * zoomFactor;
251
+ let newHeight = cropArea.height * zoomFactor;
252
+ // Limit minimum size
253
+ const minSize = 50;
254
+ if (newWidth < minSize || newHeight < minSize) {
255
+ return;
256
+ }
257
+ // Limit to image bounds
258
+ if (newWidth > image.width) {
259
+ newWidth = image.width;
260
+ newHeight = newWidth / currentAspectRatio;
261
+ }
262
+ if (newHeight > image.height) {
263
+ newHeight = image.height;
264
+ newWidth = newHeight * currentAspectRatio;
265
+ }
266
+ // Calculate new position to keep center in the same place
267
+ let newX = centerX - newWidth / 2;
268
+ let newY = centerY - newHeight / 2;
269
+ // Ensure crop area stays within image bounds
270
+ newX = Math.max(0, Math.min(image.width - newWidth, newX));
271
+ newY = Math.max(0, Math.min(image.height - newHeight, newY));
272
+ cropArea = {
273
+ x: newX,
274
+ y: newY,
275
+ width: newWidth,
276
+ height: newHeight
277
+ };
278
+ // Check if crop area fits in canvas, if not, zoom out the viewport
279
+ if (onViewportChange) {
280
+ const totalScale = viewport.scale * viewport.zoom;
281
+ const cropWidthOnCanvas = newWidth * totalScale;
282
+ const cropHeightOnCanvas = newHeight * totalScale;
283
+ // Use 90% of canvas size to add some padding
284
+ const targetWidth = canvas.width * 0.9;
285
+ const targetHeight = canvas.height * 0.9;
286
+ // If crop area is larger than canvas, calculate required zoom
287
+ if (cropWidthOnCanvas > targetWidth || cropHeightOnCanvas > targetHeight) {
288
+ const requiredZoomWidth = targetWidth / (newWidth * viewport.scale);
289
+ const requiredZoomHeight = targetHeight / (newHeight * viewport.scale);
290
+ const requiredZoom = Math.min(requiredZoomWidth, requiredZoomHeight);
291
+ // Only zoom out, never zoom in automatically
292
+ if (requiredZoom < viewport.zoom) {
293
+ onViewportChange({ zoom: requiredZoom });
294
+ }
295
+ }
296
+ }
297
+ }
298
+ function handlePinchZoomStart(event) {
299
+ if (!canvas || !canvasCoords || event.touches.length !== 2)
300
+ return;
301
+ const rect = canvas.getBoundingClientRect();
302
+ const touch1 = event.touches[0];
303
+ const touch2 = event.touches[1];
304
+ const touch1X = touch1.clientX - rect.left;
305
+ const touch1Y = touch1.clientY - rect.top;
306
+ const touch2X = touch2.clientX - rect.left;
307
+ const touch2Y = touch2.clientY - rect.top;
308
+ // Check if both touches are inside crop area
309
+ const touch1Inside = touch1X >= canvasCoords.x &&
310
+ touch1X <= canvasCoords.x + canvasCoords.width &&
311
+ touch1Y >= canvasCoords.y &&
312
+ touch1Y <= canvasCoords.y + canvasCoords.height;
313
+ const touch2Inside = touch2X >= canvasCoords.x &&
314
+ touch2X <= canvasCoords.x + canvasCoords.width &&
315
+ touch2Y >= canvasCoords.y &&
316
+ touch2Y <= canvasCoords.y + canvasCoords.height;
317
+ // If both touches are outside crop area, let event bubble for viewport zoom
318
+ if (!touch1Inside && !touch2Inside) {
319
+ return;
320
+ }
321
+ event.preventDefault();
322
+ const distance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
323
+ initialPinchDistance = distance;
324
+ initialCropSize = { width: cropArea.width, height: cropArea.height };
325
+ }
326
+ function handlePinchZoomMove(event) {
327
+ if (!image || !canvas || !canvasCoords || event.touches.length !== 2)
328
+ return;
329
+ if (initialPinchDistance === 0 || !initialCropSize)
330
+ return;
331
+ const rect = canvas.getBoundingClientRect();
332
+ const touch1 = event.touches[0];
333
+ const touch2 = event.touches[1];
334
+ const touch1X = touch1.clientX - rect.left;
335
+ const touch1Y = touch1.clientY - rect.top;
336
+ const touch2X = touch2.clientX - rect.left;
337
+ const touch2Y = touch2.clientY - rect.top;
338
+ // Check if both touches are inside crop area
339
+ const touch1Inside = touch1X >= canvasCoords.x &&
340
+ touch1X <= canvasCoords.x + canvasCoords.width &&
341
+ touch1Y >= canvasCoords.y &&
342
+ touch1Y <= canvasCoords.y + canvasCoords.height;
343
+ const touch2Inside = touch2X >= canvasCoords.x &&
344
+ touch2X <= canvasCoords.x + canvasCoords.width &&
345
+ touch2Y >= canvasCoords.y &&
346
+ touch2Y <= canvasCoords.y + canvasCoords.height;
347
+ // If both touches are outside crop area, let event bubble
348
+ if (!touch1Inside && !touch2Inside) {
349
+ handlePinchZoomEnd();
350
+ return;
351
+ }
352
+ event.preventDefault();
353
+ const distance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
354
+ const scale = distance / initialPinchDistance;
355
+ const currentAspectRatio = cropArea.width / cropArea.height;
356
+ const centerX = cropArea.x + cropArea.width / 2;
357
+ const centerY = cropArea.y + cropArea.height / 2;
358
+ let newWidth = initialCropSize.width * scale;
359
+ let newHeight = initialCropSize.height * scale;
360
+ // Limit minimum size
361
+ const minSize = 50;
362
+ if (newWidth < minSize || newHeight < minSize) {
363
+ return;
364
+ }
365
+ // Limit to image bounds
366
+ if (newWidth > image.width) {
367
+ newWidth = image.width;
368
+ newHeight = newWidth / currentAspectRatio;
369
+ }
370
+ if (newHeight > image.height) {
371
+ newHeight = image.height;
372
+ newWidth = newHeight * currentAspectRatio;
373
+ }
374
+ // Calculate new position to keep center in the same place
375
+ let newX = centerX - newWidth / 2;
376
+ let newY = centerY - newHeight / 2;
377
+ // Ensure crop area stays within image bounds
378
+ newX = Math.max(0, Math.min(image.width - newWidth, newX));
379
+ newY = Math.max(0, Math.min(image.height - newHeight, newY));
380
+ cropArea = {
381
+ x: newX,
382
+ y: newY,
383
+ width: newWidth,
384
+ height: newHeight
385
+ };
386
+ // Check if crop area fits in canvas, adjust viewport if needed
387
+ if (onViewportChange) {
388
+ const totalScale = viewport.scale * viewport.zoom;
389
+ const cropWidthOnCanvas = newWidth * totalScale;
390
+ const cropHeightOnCanvas = newHeight * totalScale;
391
+ const targetWidth = canvas.width * 0.9;
392
+ const targetHeight = canvas.height * 0.9;
393
+ if (cropWidthOnCanvas > targetWidth || cropHeightOnCanvas > targetHeight) {
394
+ const requiredZoomWidth = targetWidth / (newWidth * viewport.scale);
395
+ const requiredZoomHeight = targetHeight / (newHeight * viewport.scale);
396
+ const requiredZoom = Math.min(requiredZoomWidth, requiredZoomHeight);
397
+ if (requiredZoom < viewport.zoom) {
398
+ onViewportChange({ zoom: requiredZoom });
399
+ }
400
+ }
401
+ }
402
+ }
403
+ function handlePinchZoomEnd() {
404
+ initialPinchDistance = 0;
405
+ initialCropSize = null;
406
+ }
407
+ // Unified touch handlers that delegate based on finger count
408
+ function handleContainerTouchStartUnified(event) {
409
+ // Two fingers = pinch zoom crop area
410
+ if (event.touches.length === 2) {
411
+ handlePinchZoomStart(event);
412
+ }
413
+ else if (event.touches.length === 1) {
414
+ // Single finger = pan viewport (if outside crop) or let SVG handle drag/resize (if inside)
415
+ handleContainerMouseDown(event);
416
+ }
417
+ }
418
+ function handleContainerTouchMoveUnified(event) {
419
+ // If pinch is active (2 fingers)
420
+ if (event.touches.length === 2 && initialPinchDistance > 0) {
421
+ handlePinchZoomMove(event);
422
+ }
423
+ else {
424
+ // Single finger move (pan viewport, or drag/resize crop handled by mouse move)
425
+ handleMouseMove(event);
426
+ }
427
+ }
428
+ function handleContainerTouchEndUnified(event) {
429
+ if (event.touches.length === 0) {
430
+ // All fingers lifted
431
+ handleMouseUp();
432
+ handlePinchZoomEnd();
433
+ }
434
+ else if (event.touches.length === 1 && initialPinchDistance > 0) {
435
+ // Went from 2 fingers to 1 finger
436
+ handlePinchZoomEnd();
437
+ }
438
+ }
439
+ function rotateLeft() {
440
+ if (!onTransformChange)
441
+ return;
442
+ const newRotation = (transform.rotation - 90 + 360) % 360;
443
+ onTransformChange({ rotation: newRotation });
444
+ }
445
+ function rotateRight() {
446
+ if (!onTransformChange)
447
+ return;
448
+ const newRotation = (transform.rotation + 90) % 360;
449
+ onTransformChange({ rotation: newRotation });
450
+ }
451
+ function toggleFlipHorizontal() {
452
+ if (!onTransformChange)
453
+ return;
454
+ onTransformChange({ flipHorizontal: !transform.flipHorizontal });
455
+ }
456
+ function toggleFlipVertical() {
457
+ if (!onTransformChange)
458
+ return;
459
+ onTransformChange({ flipVertical: !transform.flipVertical });
460
+ }
461
+ </script>
462
+
463
+ <svelte:window
464
+ onmousemove={handleMouseMove}
465
+ onmouseup={handleMouseUp}
466
+ />
467
+
468
+ {#if canvasCoords && canvas}
469
+ <div
470
+ bind:this={containerElement}
471
+ class="crop-container"
472
+ class:panning={isPanning}
473
+ onwheel={handleWheel}
474
+ onmousedown={handleContainerMouseDown}
475
+ >
476
+ <svg
477
+ class="crop-overlay"
478
+ style="
479
+ position: absolute;
480
+ left: 0;
481
+ top: 0;
482
+ width: {canvas.width}px;
483
+ height: {canvas.height}px;
484
+ pointer-events: none;
485
+ "
486
+ >
487
+ <!-- Dark overlay outside crop area -->
488
+ <defs>
489
+ <mask id="crop-mask">
490
+ <rect width="100%" height="100%" fill="white" />
491
+ <rect
492
+ x={canvasCoords.x}
493
+ y={canvasCoords.y}
494
+ width={canvasCoords.width}
495
+ height={canvasCoords.height}
496
+ fill="black"
497
+ />
498
+ </mask>
499
+ </defs>
500
+ <rect
501
+ width="100%"
502
+ height="100%"
503
+ fill="rgba(0, 0, 0, 0.5)"
504
+ mask="url(#crop-mask)"
505
+ style="pointer-events: none;"
506
+ />
507
+
508
+ <!-- Crop area border with dashed line -->
509
+ <rect
510
+ x={canvasCoords.x}
511
+ y={canvasCoords.y}
512
+ width={canvasCoords.width}
513
+ height={canvasCoords.height}
514
+ fill="none"
515
+ stroke="var(--primary-color, #63b97b)"
516
+ stroke-width="2"
517
+ stroke-dasharray="5,5"
518
+ style="pointer-events: all; cursor: move;"
519
+ onmousedown={(e) => handleMouseDown(e)}
520
+ ontouchstart={(e) => handleMouseDown(e)}
521
+ />
522
+
523
+ <!-- Grid lines (rule of thirds) -->
524
+ <line
525
+ x1={canvasCoords.x + canvasCoords.width / 3}
526
+ y1={canvasCoords.y}
527
+ x2={canvasCoords.x + canvasCoords.width / 3}
528
+ y2={canvasCoords.y + canvasCoords.height}
529
+ stroke="rgba(255, 255, 255, 0.3)"
530
+ stroke-width="1"
531
+ style="pointer-events: none;"
532
+ />
533
+ <line
534
+ x1={canvasCoords.x + (canvasCoords.width * 2) / 3}
535
+ y1={canvasCoords.y}
536
+ x2={canvasCoords.x + (canvasCoords.width * 2) / 3}
537
+ y2={canvasCoords.y + canvasCoords.height}
538
+ stroke="rgba(255, 255, 255, 0.3)"
539
+ stroke-width="1"
540
+ style="pointer-events: none;"
541
+ />
542
+ <line
543
+ x1={canvasCoords.x}
544
+ y1={canvasCoords.y + canvasCoords.height / 3}
545
+ x2={canvasCoords.x + canvasCoords.width}
546
+ y2={canvasCoords.y + canvasCoords.height / 3}
547
+ stroke="rgba(255, 255, 255, 0.3)"
548
+ stroke-width="1"
549
+ style="pointer-events: none;"
550
+ />
551
+ <line
552
+ x1={canvasCoords.x}
553
+ y1={canvasCoords.y + (canvasCoords.height * 2) / 3}
554
+ x2={canvasCoords.x + canvasCoords.width}
555
+ y2={canvasCoords.y + (canvasCoords.height * 2) / 3}
556
+ stroke="rgba(255, 255, 255, 0.3)"
557
+ stroke-width="1"
558
+ style="pointer-events: none;"
559
+ />
560
+
561
+ <!-- Resize handles -->
562
+ <!-- Corners -->
563
+ <circle
564
+ cx={canvasCoords.x}
565
+ cy={canvasCoords.y}
566
+ r="6"
567
+ fill="var(--primary-color, #63b97b)"
568
+ stroke="white"
569
+ stroke-width="2"
570
+ style="pointer-events: all; cursor: nw-resize;"
571
+ onmousedown={(e) => handleMouseDown(e, 'nw')}
572
+ ontouchstart={(e) => handleMouseDown(e, 'nw')}
573
+ />
574
+ <circle
575
+ cx={canvasCoords.x + canvasCoords.width}
576
+ cy={canvasCoords.y}
577
+ r="6"
578
+ fill="var(--primary-color, #63b97b)"
579
+ stroke="white"
580
+ stroke-width="2"
581
+ style="pointer-events: all; cursor: ne-resize;"
582
+ onmousedown={(e) => handleMouseDown(e, 'ne')}
583
+ ontouchstart={(e) => handleMouseDown(e, 'ne')}
584
+ />
585
+ <circle
586
+ cx={canvasCoords.x}
587
+ cy={canvasCoords.y + canvasCoords.height}
588
+ r="6"
589
+ fill="var(--primary-color, #63b97b)"
590
+ stroke="white"
591
+ stroke-width="2"
592
+ style="pointer-events: all; cursor: sw-resize;"
593
+ onmousedown={(e) => handleMouseDown(e, 'sw')}
594
+ ontouchstart={(e) => handleMouseDown(e, 'sw')}
595
+ />
596
+ <circle
597
+ cx={canvasCoords.x + canvasCoords.width}
598
+ cy={canvasCoords.y + canvasCoords.height}
599
+ r="6"
600
+ fill="var(--primary-color, #63b97b)"
601
+ stroke="white"
602
+ stroke-width="2"
603
+ style="pointer-events: all; cursor: se-resize;"
604
+ onmousedown={(e) => handleMouseDown(e, 'se')}
605
+ ontouchstart={(e) => handleMouseDown(e, 'se')}
606
+ />
607
+
608
+ <!-- Edges -->
609
+ <circle
610
+ cx={canvasCoords.x + canvasCoords.width / 2}
611
+ cy={canvasCoords.y}
612
+ r="6"
613
+ fill="var(--primary-color, #63b97b)"
614
+ stroke="white"
615
+ stroke-width="2"
616
+ style="pointer-events: all; cursor: n-resize;"
617
+ onmousedown={(e) => handleMouseDown(e, 'n')}
618
+ ontouchstart={(e) => handleMouseDown(e, 'n')}
619
+ />
620
+ <circle
621
+ cx={canvasCoords.x + canvasCoords.width}
622
+ cy={canvasCoords.y + canvasCoords.height / 2}
623
+ r="6"
624
+ fill="var(--primary-color, #63b97b)"
625
+ stroke="white"
626
+ stroke-width="2"
627
+ style="pointer-events: all; cursor: e-resize;"
628
+ onmousedown={(e) => handleMouseDown(e, 'e')}
629
+ ontouchstart={(e) => handleMouseDown(e, 'e')}
630
+ />
631
+ <circle
632
+ cx={canvasCoords.x + canvasCoords.width / 2}
633
+ cy={canvasCoords.y + canvasCoords.height}
634
+ r="6"
635
+ fill="var(--primary-color, #63b97b)"
636
+ stroke="white"
637
+ stroke-width="2"
638
+ style="pointer-events: all; cursor: s-resize;"
639
+ onmousedown={(e) => handleMouseDown(e, 's')}
640
+ ontouchstart={(e) => handleMouseDown(e, 's')}
641
+ />
642
+ <circle
643
+ cx={canvasCoords.x}
644
+ cy={canvasCoords.y + canvasCoords.height / 2}
645
+ r="6"
646
+ fill="var(--primary-color, #63b97b)"
647
+ stroke="white"
648
+ stroke-width="2"
649
+ style="pointer-events: all; cursor: w-resize;"
650
+ onmousedown={(e) => handleMouseDown(e, 'w')}
651
+ ontouchstart={(e) => handleMouseDown(e, 'w')}
652
+ />
653
+ </svg>
654
+
655
+ <!-- Aspect ratio and transform controls -->
656
+ <div class="crop-top-controls">
657
+ <div class="transform-controls">
658
+ <div class="control-group">
659
+ <div class="control-label">{$_('editor.rotate')}</div>
660
+ <div class="button-group">
661
+ <button class="transform-btn" onclick={rotateLeft} title={$_('editor.rotateLeft')}>
662
+ <RotateCcw size={18} />
663
+ </button>
664
+ <button class="transform-btn" onclick={rotateRight} title={$_('editor.rotateRight')}>
665
+ <RotateCw size={18} />
666
+ </button>
667
+ </div>
668
+ </div>
669
+
670
+ <div class="control-group">
671
+ <div class="control-label">{$_('editor.flip')}</div>
672
+ <div class="button-group">
673
+ <button
674
+ class="transform-btn"
675
+ class:active={transform.flipHorizontal}
676
+ onclick={toggleFlipHorizontal}
677
+ title={$_('editor.flipHorizontal')}
678
+ >
679
+ <FlipHorizontal size={18} />
680
+ </button>
681
+ <button
682
+ class="transform-btn"
683
+ class:active={transform.flipVertical}
684
+ onclick={toggleFlipVertical}
685
+ title={$_('editor.flipVertical')}
686
+ >
687
+ <FlipVertical size={18} />
688
+ </button>
689
+ </div>
690
+ </div>
691
+ </div>
692
+
693
+ <div class="aspect-ratio-controls">
694
+ <button class="aspect-btn" onclick={() => setAspectRatio(16/9)}>
695
+ 16:9
696
+ </button>
697
+ <button class="aspect-btn" onclick={() => setAspectRatio(3/2)}>
698
+ 3:2
699
+ </button>
700
+ <button class="aspect-btn" onclick={() => setAspectRatio(1/1)}>
701
+ 1:1
702
+ </button>
703
+ </div>
704
+ </div>
705
+
706
+ <!-- Control buttons -->
707
+ <div class="crop-controls">
708
+ <button class="btn btn-primary" onclick={apply}>
709
+ {$_('editor.apply')}
710
+ </button>
711
+ <button class="btn btn-secondary" onclick={onCancel}>
712
+ {$_('editor.cancel')}
713
+ </button>
714
+ </div>
715
+ </div>
716
+ {/if}
717
+
718
+ <style>
719
+ .crop-container {
720
+ position: absolute;
721
+ inset: 0;
722
+ z-index: 10;
723
+ cursor: grab;
724
+ }
725
+
726
+ .crop-container.panning {
727
+ cursor: grabbing;
728
+ }
729
+
730
+ .crop-overlay {
731
+ pointer-events: none;
732
+ z-index: 10;
733
+ }
734
+
735
+ .crop-top-controls {
736
+ position: absolute;
737
+ top: 1rem;
738
+ left: 50%;
739
+ transform: translateX(-50%);
740
+ display: flex;
741
+ align-items: center;
742
+ flex-direction: column;
743
+ gap: 0.75rem;
744
+ z-index: 20;
745
+ }
746
+
747
+ @media (max-width: 767px) {
748
+
749
+ .crop-top-controls {
750
+ top: 0.5rem;
751
+ gap: 0.5rem;
752
+ max-width: 90vw
753
+ }
754
+ }
755
+
756
+ .aspect-ratio-controls {
757
+ display: flex;
758
+ align-items: center;
759
+ justify-content: center;
760
+ gap: 0.5rem;
761
+ padding: 0.5rem 1rem;
762
+ background: rgba(0, 0, 0, 0.8);
763
+ border-radius: 4px;
764
+ width: fit-content;
765
+ }
766
+
767
+ @media (max-width: 767px) {
768
+
769
+ .aspect-ratio-controls {
770
+ padding: 0.4rem 0.6rem;
771
+ gap: 0.3rem
772
+ }
773
+ }
774
+
775
+ .transform-controls {
776
+ display: flex;
777
+ gap: 1rem;
778
+ padding: 0.5rem 1rem;
779
+ background: rgba(0, 0, 0, 0.8);
780
+ border-radius: 4px;
781
+ }
782
+
783
+ @media (max-width: 767px) {
784
+
785
+ .transform-controls {
786
+ gap: 0.5rem;
787
+ padding: 0.4rem 0.6rem
788
+ }
789
+ }
790
+
791
+ .control-group {
792
+ display: flex;
793
+ align-items: center;
794
+ gap: 0.5rem;
795
+ }
796
+
797
+ .control-label {
798
+ font-size: 0.85rem;
799
+ color: #ccc;
800
+ margin-right: 0.25rem;
801
+ }
802
+
803
+ @media (max-width: 767px) {
804
+
805
+ .control-label {
806
+ font-size: 0.7rem;
807
+ display: none
808
+ }
809
+ }
810
+
811
+ .button-group {
812
+ display: flex;
813
+ gap: 0.25rem;
814
+ }
815
+
816
+ @media (max-width: 767px) {
817
+
818
+ .button-group {
819
+ gap: 0.2rem
820
+ }
821
+ }
822
+
823
+ .aspect-btn {
824
+ padding: 0.4rem 0.8rem;
825
+ background: #333;
826
+ color: #fff;
827
+ border: 1px solid #555;
828
+ border-radius: 4px;
829
+ cursor: pointer;
830
+ font-size: 0.85rem;
831
+ transition: all 0.2s;
832
+ }
833
+
834
+ @media (max-width: 767px) {
835
+
836
+ .aspect-btn {
837
+ padding: 0.3rem 0.6rem;
838
+ font-size: 0.75rem
839
+ }
840
+ }
841
+
842
+ .aspect-btn:hover {
843
+ background: var(--primary-color, #63b97b);
844
+ border-color: var(--primary-color, #63b97b);
845
+ }
846
+
847
+ .transform-btn {
848
+ padding: 0.4rem 0.6rem;
849
+ background: #333;
850
+ color: #fff;
851
+ border: 1px solid #555;
852
+ border-radius: 4px;
853
+ cursor: pointer;
854
+ font-size: 0.85rem;
855
+ transition: all 0.2s;
856
+ display: flex;
857
+ align-items: center;
858
+ justify-content: center;
859
+ }
860
+
861
+ @media (max-width: 767px) {
862
+
863
+ .transform-btn {
864
+ padding: 0.3rem 0.5rem
865
+ }
866
+ }
867
+
868
+ .transform-btn:hover {
869
+ background: #444;
870
+ border-color: #666;
871
+ }
872
+
873
+ .transform-btn.active {
874
+ background: var(--primary-color, #63b97b);
875
+ border-color: var(--primary-color, #63b97b);
876
+ }
877
+
878
+ .crop-controls {
879
+ position: absolute;
880
+ bottom: 1rem;
881
+ left: 50%;
882
+ transform: translateX(-50%);
883
+ display: flex;
884
+ gap: 0.5rem;
885
+ z-index: 20;
886
+ }
887
+
888
+ @media (max-width: 767px) {
889
+
890
+ .crop-controls {
891
+ bottom: 0.5rem;
892
+ left: 1rem;
893
+ right: 1rem;
894
+ transform: none;
895
+ width: calc(100% - 2rem);
896
+ justify-content: stretch
897
+ }
898
+ }
899
+
900
+ .btn {
901
+ padding: 0.5rem 1rem;
902
+ border: none;
903
+ border-radius: 4px;
904
+ cursor: pointer;
905
+ font-size: 0.9rem;
906
+ transition: all 0.2s;
907
+ }
908
+
909
+ @media (max-width: 767px) {
910
+
911
+ .btn {
912
+ flex: 1;
913
+ padding: 0.75rem 1rem;
914
+ font-size: 1rem
915
+ }
916
+ }
917
+
918
+ .btn-primary {
919
+ background: var(--primary-color, #63b97b);
920
+ color: #fff;
921
+ }
922
+
923
+ .btn-primary:hover {
924
+ background: var(--primary-color, #63b97b);
925
+ }
926
+
927
+ .btn-secondary {
928
+ background: #666;
929
+ color: #fff;
930
+ }
931
+
932
+ .btn-secondary:hover {
933
+ background: #777;
934
+ }
935
+
936
+ /* Larger touch targets for mobile */
937
+ @media (max-width: 767px) {
938
+ .crop-overlay circle {
939
+ r: 12 !important;
940
+ stroke-width: 3 !important;
941
+ }
942
+ }</style>