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.
- package/dist/components/AdjustTool.svelte +317 -0
- package/dist/components/AdjustTool.svelte.d.ts +9 -0
- package/dist/components/BlurTool.svelte +613 -0
- package/dist/components/BlurTool.svelte.d.ts +15 -0
- package/dist/components/Canvas.svelte +214 -0
- package/dist/components/Canvas.svelte.d.ts +17 -0
- package/dist/components/CropTool.svelte +942 -0
- package/dist/components/CropTool.svelte.d.ts +14 -0
- package/dist/components/ExportTool.svelte +191 -0
- package/dist/components/ExportTool.svelte.d.ts +10 -0
- package/dist/components/FilterTool.svelte +492 -0
- package/dist/components/FilterTool.svelte.d.ts +12 -0
- package/dist/components/ImageEditor.svelte +735 -0
- package/dist/components/ImageEditor.svelte.d.ts +12 -0
- package/dist/components/RotateTool.svelte +157 -0
- package/dist/components/RotateTool.svelte.d.ts +9 -0
- package/dist/components/StampTool.svelte +678 -0
- package/dist/components/StampTool.svelte.d.ts +15 -0
- package/dist/components/Toolbar.svelte +136 -0
- package/dist/components/Toolbar.svelte.d.ts +10 -0
- package/dist/config/stamps.d.ts +2 -0
- package/dist/config/stamps.js +22 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +9 -0
- package/dist/i18n/locales/en.json +68 -0
- package/dist/i18n/locales/ja.json +68 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +1 -0
- package/dist/utils/adjustments.d.ts +26 -0
- package/dist/utils/adjustments.js +525 -0
- package/dist/utils/canvas.d.ts +30 -0
- package/dist/utils/canvas.js +293 -0
- package/dist/utils/filters.d.ts +18 -0
- package/dist/utils/filters.js +114 -0
- package/dist/utils/history.d.ts +15 -0
- package/dist/utils/history.js +67 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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>
|