tokimeki-image-editor 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AdjustTool.svelte +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,678 @@
|
|
|
1
|
+
<script lang="ts">import { onMount } from 'svelte';
|
|
2
|
+
import { _ } from 'svelte-i18n';
|
|
3
|
+
import { STAMP_ASSETS } from '../config/stamps';
|
|
4
|
+
import { preloadStampImage } from '../utils/canvas';
|
|
5
|
+
import { RotateCw, Trash2 } from 'lucide-svelte';
|
|
6
|
+
let { canvas, image, viewport, transform, stampAreas, cropArea, onUpdate, onClose, onViewportChange } = $props();
|
|
7
|
+
let overlayElement = $state(null);
|
|
8
|
+
// Helper to get coordinates from mouse or touch event
|
|
9
|
+
function getEventCoords(event) {
|
|
10
|
+
if ('touches' in event && event.touches.length > 0) {
|
|
11
|
+
return { clientX: event.touches[0].clientX, clientY: event.touches[0].clientY };
|
|
12
|
+
}
|
|
13
|
+
else if ('clientX' in event) {
|
|
14
|
+
return { clientX: event.clientX, clientY: event.clientY };
|
|
15
|
+
}
|
|
16
|
+
return { clientX: 0, clientY: 0 };
|
|
17
|
+
}
|
|
18
|
+
onMount(() => {
|
|
19
|
+
if (overlayElement) {
|
|
20
|
+
// Add touch event listeners with passive: false to allow preventDefault
|
|
21
|
+
overlayElement.addEventListener('touchstart', handleCanvasTouchStart, { passive: false });
|
|
22
|
+
overlayElement.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
23
|
+
overlayElement.addEventListener('touchend', handleTouchEnd, { passive: false });
|
|
24
|
+
}
|
|
25
|
+
return () => {
|
|
26
|
+
if (overlayElement) {
|
|
27
|
+
overlayElement.removeEventListener('touchstart', handleCanvasTouchStart);
|
|
28
|
+
overlayElement.removeEventListener('touchmove', handleTouchMove);
|
|
29
|
+
overlayElement.removeEventListener('touchend', handleTouchEnd);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
// Default stamp size as percentage of the smaller dimension of the image
|
|
34
|
+
const DEFAULT_STAMP_SIZE_PERCENT = 0.1; // 10%
|
|
35
|
+
let selectedStampAsset = $state(null);
|
|
36
|
+
let selectedStampId = $state(null);
|
|
37
|
+
let isDragging = $state(false);
|
|
38
|
+
let isResizing = $state(false);
|
|
39
|
+
let isRotating = $state(false);
|
|
40
|
+
let resizeHandle = $state(null);
|
|
41
|
+
let dragStart = $state({ x: 0, y: 0 });
|
|
42
|
+
let initialStamp = $state(null);
|
|
43
|
+
let initialRotation = $state(0);
|
|
44
|
+
let initialAngle = $state(0);
|
|
45
|
+
let rotationCenter = $state({ x: 0, y: 0 });
|
|
46
|
+
// Viewport panning
|
|
47
|
+
let isPanning = $state(false);
|
|
48
|
+
let lastPanPosition = $state({ x: 0, y: 0 });
|
|
49
|
+
// Convert stamp areas to canvas coordinates for rendering
|
|
50
|
+
let canvasStampAreas = $derived.by(() => {
|
|
51
|
+
if (!canvas || !image)
|
|
52
|
+
return [];
|
|
53
|
+
return stampAreas.map(area => {
|
|
54
|
+
const sourceWidth = cropArea ? cropArea.width : image.width;
|
|
55
|
+
const sourceHeight = cropArea ? cropArea.height : image.height;
|
|
56
|
+
const offsetX = cropArea ? cropArea.x : 0;
|
|
57
|
+
const offsetY = cropArea ? cropArea.y : 0;
|
|
58
|
+
const relativeX = area.x - offsetX;
|
|
59
|
+
const relativeY = area.y - offsetY;
|
|
60
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
61
|
+
const centerX = canvas.width / 2;
|
|
62
|
+
const centerY = canvas.height / 2;
|
|
63
|
+
const canvasCenterX = (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX;
|
|
64
|
+
const canvasCenterY = (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY;
|
|
65
|
+
const canvasWidth = area.width * totalScale;
|
|
66
|
+
const canvasHeight = area.height * totalScale;
|
|
67
|
+
return {
|
|
68
|
+
...area,
|
|
69
|
+
canvasCenterX,
|
|
70
|
+
canvasCenterY,
|
|
71
|
+
canvasWidth,
|
|
72
|
+
canvasHeight
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
function handleCanvasMouseDown(event) {
|
|
77
|
+
if (!canvas || !image)
|
|
78
|
+
return;
|
|
79
|
+
const coords = getEventCoords(event);
|
|
80
|
+
const rect = canvas.getBoundingClientRect();
|
|
81
|
+
const mouseX = coords.clientX - rect.left;
|
|
82
|
+
const mouseY = coords.clientY - rect.top;
|
|
83
|
+
// Check if clicking on rotation handle (se corner)
|
|
84
|
+
for (const canvasStamp of canvasStampAreas) {
|
|
85
|
+
const stamp = stampAreas.find(s => s.id === canvasStamp.id);
|
|
86
|
+
if (!stamp)
|
|
87
|
+
continue;
|
|
88
|
+
const rotHandle = getRotationHandlePosition(canvasStamp);
|
|
89
|
+
const dist = Math.hypot(mouseX - rotHandle.x, mouseY - rotHandle.y);
|
|
90
|
+
if (dist <= 10) {
|
|
91
|
+
isRotating = true;
|
|
92
|
+
selectedStampId = stamp.id;
|
|
93
|
+
dragStart = { x: coords.clientX, y: coords.clientY };
|
|
94
|
+
initialStamp = { ...stamp };
|
|
95
|
+
initialRotation = stamp.rotation || 0;
|
|
96
|
+
rotationCenter = { x: canvasStamp.canvasCenterX, y: canvasStamp.canvasCenterY };
|
|
97
|
+
// Calculate initial angle from center to mouse position
|
|
98
|
+
initialAngle = Math.atan2(mouseY - rotationCenter.y, mouseX - rotationCenter.x) * (180 / Math.PI);
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Check if clicking on resize handle (nw, ne, sw corners only)
|
|
104
|
+
for (const canvasStamp of canvasStampAreas) {
|
|
105
|
+
const stamp = stampAreas.find(s => s.id === canvasStamp.id);
|
|
106
|
+
if (!stamp)
|
|
107
|
+
continue;
|
|
108
|
+
const handle = getResizeHandle(mouseX, mouseY, canvasStamp);
|
|
109
|
+
if (handle) {
|
|
110
|
+
isResizing = true;
|
|
111
|
+
resizeHandle = handle;
|
|
112
|
+
selectedStampId = stamp.id;
|
|
113
|
+
dragStart = { x: coords.clientX, y: coords.clientY };
|
|
114
|
+
initialStamp = { ...stamp };
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Check if clicking on a stamp
|
|
120
|
+
for (const canvasStamp of canvasStampAreas) {
|
|
121
|
+
if (isPointInStamp(mouseX, mouseY, canvasStamp)) {
|
|
122
|
+
const stamp = stampAreas.find(s => s.id === canvasStamp.id);
|
|
123
|
+
if (stamp) {
|
|
124
|
+
selectedStampId = stamp.id;
|
|
125
|
+
isDragging = true;
|
|
126
|
+
dragStart = { x: coords.clientX, y: coords.clientY };
|
|
127
|
+
initialStamp = { ...stamp };
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// If clicking outside any stamp, deselect and start panning
|
|
134
|
+
selectedStampId = null;
|
|
135
|
+
isPanning = true;
|
|
136
|
+
lastPanPosition = { x: coords.clientX, y: coords.clientY };
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
}
|
|
139
|
+
function handleMouseMove(event) {
|
|
140
|
+
if (!canvas || !image)
|
|
141
|
+
return;
|
|
142
|
+
const coords = getEventCoords(event);
|
|
143
|
+
// Handle panning
|
|
144
|
+
if (isPanning && onViewportChange) {
|
|
145
|
+
const deltaX = coords.clientX - lastPanPosition.x;
|
|
146
|
+
const deltaY = coords.clientY - lastPanPosition.y;
|
|
147
|
+
const imgWidth = image.width;
|
|
148
|
+
const imgHeight = image.height;
|
|
149
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
150
|
+
const scaledWidth = imgWidth * totalScale;
|
|
151
|
+
const scaledHeight = imgHeight * totalScale;
|
|
152
|
+
const overflowMargin = 0.2;
|
|
153
|
+
const maxOffsetX = (scaledWidth / 2) - (canvas.width / 2) + (canvas.width * overflowMargin);
|
|
154
|
+
const maxOffsetY = (scaledHeight / 2) - (canvas.height / 2) + (canvas.height * overflowMargin);
|
|
155
|
+
const newOffsetX = viewport.offsetX + deltaX;
|
|
156
|
+
const newOffsetY = viewport.offsetY + deltaY;
|
|
157
|
+
const clampedOffsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, newOffsetX));
|
|
158
|
+
const clampedOffsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, newOffsetY));
|
|
159
|
+
onViewportChange({
|
|
160
|
+
offsetX: clampedOffsetX,
|
|
161
|
+
offsetY: clampedOffsetY
|
|
162
|
+
});
|
|
163
|
+
lastPanPosition = { x: coords.clientX, y: coords.clientY };
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Handle dragging
|
|
168
|
+
if (isDragging && initialStamp && selectedStampId) {
|
|
169
|
+
const deltaX = coords.clientX - dragStart.x;
|
|
170
|
+
const deltaY = coords.clientY - dragStart.y;
|
|
171
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
172
|
+
const imgDeltaX = deltaX / totalScale;
|
|
173
|
+
const imgDeltaY = deltaY / totalScale;
|
|
174
|
+
const newX = initialStamp.x + imgDeltaX;
|
|
175
|
+
const newY = initialStamp.y + imgDeltaY;
|
|
176
|
+
const updatedAreas = stampAreas.map(area => area.id === selectedStampId
|
|
177
|
+
? { ...area, x: newX, y: newY }
|
|
178
|
+
: area);
|
|
179
|
+
onUpdate(updatedAreas);
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Handle resizing
|
|
184
|
+
if (isResizing && initialStamp && resizeHandle && selectedStampId) {
|
|
185
|
+
const deltaX = coords.clientX - dragStart.x;
|
|
186
|
+
const deltaY = coords.clientY - dragStart.y;
|
|
187
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
188
|
+
const imgDeltaX = deltaX / totalScale;
|
|
189
|
+
const imgDeltaY = deltaY / totalScale;
|
|
190
|
+
// Calculate new size maintaining aspect ratio
|
|
191
|
+
const aspectRatio = initialStamp.width / initialStamp.height;
|
|
192
|
+
// Determine resize direction based on handle
|
|
193
|
+
let sizeDelta = 0;
|
|
194
|
+
switch (resizeHandle) {
|
|
195
|
+
case 'nw':
|
|
196
|
+
// Resize from top-left corner (inverse)
|
|
197
|
+
sizeDelta = -(imgDeltaX + imgDeltaY) / 2;
|
|
198
|
+
break;
|
|
199
|
+
case 'ne':
|
|
200
|
+
// Resize from top-right corner
|
|
201
|
+
sizeDelta = (imgDeltaX - imgDeltaY) / 2;
|
|
202
|
+
break;
|
|
203
|
+
case 'sw':
|
|
204
|
+
// Resize from bottom-left corner
|
|
205
|
+
sizeDelta = (-imgDeltaX + imgDeltaY) / 2;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
let newWidth = initialStamp.width + sizeDelta;
|
|
209
|
+
let newHeight = newWidth / aspectRatio;
|
|
210
|
+
// Enforce minimum size
|
|
211
|
+
if (newWidth >= 20 && newHeight >= 20) {
|
|
212
|
+
const updatedAreas = stampAreas.map(area => area.id === selectedStampId
|
|
213
|
+
? { ...area, width: newWidth, height: newHeight }
|
|
214
|
+
: area);
|
|
215
|
+
onUpdate(updatedAreas);
|
|
216
|
+
}
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Handle rotation
|
|
221
|
+
if (isRotating && initialStamp && selectedStampId) {
|
|
222
|
+
const rect = canvas.getBoundingClientRect();
|
|
223
|
+
const mouseX = coords.clientX - rect.left;
|
|
224
|
+
const mouseY = coords.clientY - rect.top;
|
|
225
|
+
// Calculate current angle from center to mouse position
|
|
226
|
+
const currentAngle = Math.atan2(mouseY - rotationCenter.y, mouseX - rotationCenter.x) * (180 / Math.PI);
|
|
227
|
+
// Calculate angle delta and apply to initial rotation
|
|
228
|
+
const angleDelta = currentAngle - initialAngle;
|
|
229
|
+
const newRotation = initialRotation + angleDelta;
|
|
230
|
+
const updatedAreas = stampAreas.map(area => area.id === selectedStampId
|
|
231
|
+
? { ...area, rotation: newRotation }
|
|
232
|
+
: area);
|
|
233
|
+
onUpdate(updatedAreas);
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function handleMouseUp(event) {
|
|
239
|
+
isDragging = false;
|
|
240
|
+
isResizing = false;
|
|
241
|
+
isRotating = false;
|
|
242
|
+
isPanning = false;
|
|
243
|
+
resizeHandle = null;
|
|
244
|
+
initialStamp = null;
|
|
245
|
+
}
|
|
246
|
+
// Unified touch handlers
|
|
247
|
+
const handleCanvasTouchStart = handleCanvasMouseDown;
|
|
248
|
+
const handleTouchMove = handleMouseMove;
|
|
249
|
+
function handleTouchEnd(event) {
|
|
250
|
+
if (event.touches.length === 0) {
|
|
251
|
+
handleMouseUp();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function getResizeHandle(mouseX, mouseY, canvasStamp) {
|
|
255
|
+
const handles = getResizeHandles(canvasStamp);
|
|
256
|
+
for (const [handle, pos] of Object.entries(handles)) {
|
|
257
|
+
const dist = Math.hypot(mouseX - pos.x, mouseY - pos.y);
|
|
258
|
+
if (dist <= 8)
|
|
259
|
+
return handle;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
function getResizeHandles(canvasStamp) {
|
|
264
|
+
const { canvasCenterX, canvasCenterY, canvasWidth, canvasHeight, rotation } = canvasStamp;
|
|
265
|
+
const hw = canvasWidth / 2;
|
|
266
|
+
const hh = canvasHeight / 2;
|
|
267
|
+
const rad = (rotation || 0) * Math.PI / 180;
|
|
268
|
+
const cos = Math.cos(rad);
|
|
269
|
+
const sin = Math.sin(rad);
|
|
270
|
+
const rotate = (x, y) => ({
|
|
271
|
+
x: canvasCenterX + x * cos - y * sin,
|
|
272
|
+
y: canvasCenterY + x * sin + y * cos
|
|
273
|
+
});
|
|
274
|
+
// Only return corner handles (4 corners)
|
|
275
|
+
return {
|
|
276
|
+
nw: rotate(-hw, -hh),
|
|
277
|
+
ne: rotate(hw, -hh),
|
|
278
|
+
sw: rotate(-hw, hh),
|
|
279
|
+
se: rotate(hw, hh)
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function getRotationHandlePosition(canvasStamp) {
|
|
283
|
+
const handles = getResizeHandles(canvasStamp);
|
|
284
|
+
return {
|
|
285
|
+
x: handles.se.x,
|
|
286
|
+
y: handles.se.y
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function isPointInStamp(mouseX, mouseY, canvasStamp) {
|
|
290
|
+
const { canvasCenterX, canvasCenterY, canvasWidth, canvasHeight, rotation } = canvasStamp;
|
|
291
|
+
const rad = -(rotation || 0) * Math.PI / 180;
|
|
292
|
+
const cos = Math.cos(rad);
|
|
293
|
+
const sin = Math.sin(rad);
|
|
294
|
+
const dx = mouseX - canvasCenterX;
|
|
295
|
+
const dy = mouseY - canvasCenterY;
|
|
296
|
+
const localX = dx * cos - dy * sin;
|
|
297
|
+
const localY = dx * sin + dy * cos;
|
|
298
|
+
return Math.abs(localX) <= canvasWidth / 2 && Math.abs(localY) <= canvasHeight / 2;
|
|
299
|
+
}
|
|
300
|
+
function handleDeleteStamp() {
|
|
301
|
+
if (selectedStampId) {
|
|
302
|
+
const updatedAreas = stampAreas.filter(area => area.id !== selectedStampId);
|
|
303
|
+
onUpdate(updatedAreas);
|
|
304
|
+
selectedStampId = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function selectStampAsset(asset) {
|
|
308
|
+
if (!canvas || !image)
|
|
309
|
+
return;
|
|
310
|
+
selectedStampAsset = asset;
|
|
311
|
+
// Calculate center position in image coordinates
|
|
312
|
+
const sourceWidth = cropArea ? cropArea.width : image.width;
|
|
313
|
+
const sourceHeight = cropArea ? cropArea.height : image.height;
|
|
314
|
+
const offsetX = cropArea ? cropArea.x : 0;
|
|
315
|
+
const offsetY = cropArea ? cropArea.y : 0;
|
|
316
|
+
// Center of the visible area (in image coordinates)
|
|
317
|
+
const centerX = sourceWidth / 2 + offsetX;
|
|
318
|
+
const centerY = sourceHeight / 2 + offsetY;
|
|
319
|
+
// Default size: based on the smaller dimension of the image
|
|
320
|
+
const minDimension = Math.min(sourceWidth, sourceHeight);
|
|
321
|
+
const defaultSize = minDimension * DEFAULT_STAMP_SIZE_PERCENT;
|
|
322
|
+
// For emojis, use 1:1 aspect ratio
|
|
323
|
+
if (asset.type === 'emoji') {
|
|
324
|
+
const aspectRatio = 1;
|
|
325
|
+
const newStamp = {
|
|
326
|
+
id: `stamp-${Date.now()}`,
|
|
327
|
+
x: centerX,
|
|
328
|
+
y: centerY,
|
|
329
|
+
width: defaultSize,
|
|
330
|
+
height: defaultSize / aspectRatio,
|
|
331
|
+
rotation: 0,
|
|
332
|
+
stampAssetId: asset.id,
|
|
333
|
+
stampType: asset.type,
|
|
334
|
+
stampContent: asset.content
|
|
335
|
+
};
|
|
336
|
+
onUpdate([...stampAreas, newStamp]);
|
|
337
|
+
selectedStampId = newStamp.id;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// For images and SVGs, load the image first to get actual aspect ratio
|
|
341
|
+
preloadStampImage(asset.content).then((img) => {
|
|
342
|
+
const aspectRatio = img.width / img.height;
|
|
343
|
+
// Calculate width and height based on aspect ratio
|
|
344
|
+
// Use defaultSize as the width for landscape, height for portrait
|
|
345
|
+
let width, height;
|
|
346
|
+
if (aspectRatio >= 1) {
|
|
347
|
+
// Landscape or square
|
|
348
|
+
width = defaultSize;
|
|
349
|
+
height = width / aspectRatio;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// Portrait
|
|
353
|
+
height = defaultSize;
|
|
354
|
+
width = height * aspectRatio;
|
|
355
|
+
}
|
|
356
|
+
const newStamp = {
|
|
357
|
+
id: `stamp-${Date.now()}`,
|
|
358
|
+
x: centerX,
|
|
359
|
+
y: centerY,
|
|
360
|
+
width,
|
|
361
|
+
height,
|
|
362
|
+
rotation: 0,
|
|
363
|
+
stampAssetId: asset.id,
|
|
364
|
+
stampType: asset.type,
|
|
365
|
+
stampContent: asset.content
|
|
366
|
+
};
|
|
367
|
+
onUpdate([...stampAreas, newStamp]);
|
|
368
|
+
selectedStampId = newStamp.id;
|
|
369
|
+
}).catch((error) => {
|
|
370
|
+
console.error('Failed to load stamp image:', error);
|
|
371
|
+
// Fallback to square aspect ratio on error
|
|
372
|
+
const aspectRatio = 1;
|
|
373
|
+
const newStamp = {
|
|
374
|
+
id: `stamp-${Date.now()}`,
|
|
375
|
+
x: centerX,
|
|
376
|
+
y: centerY,
|
|
377
|
+
width: defaultSize,
|
|
378
|
+
height: defaultSize / aspectRatio,
|
|
379
|
+
rotation: 0,
|
|
380
|
+
stampAssetId: asset.id,
|
|
381
|
+
stampType: asset.type,
|
|
382
|
+
stampContent: asset.content
|
|
383
|
+
};
|
|
384
|
+
onUpdate([...stampAreas, newStamp]);
|
|
385
|
+
selectedStampId = newStamp.id;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<svelte:window
|
|
392
|
+
onmousemove={handleMouseMove}
|
|
393
|
+
onmouseup={handleMouseUp}
|
|
394
|
+
/>
|
|
395
|
+
|
|
396
|
+
<div class="stamp-tool">
|
|
397
|
+
<div class="stamp-palette">
|
|
398
|
+
<h3>{$_('editor.selectStamp') || 'Select Stamp'}</h3>
|
|
399
|
+
<div class="stamp-grid">
|
|
400
|
+
{#each STAMP_ASSETS as asset}
|
|
401
|
+
<button
|
|
402
|
+
class="stamp-item"
|
|
403
|
+
class:selected={selectedStampAsset?.id === asset.id}
|
|
404
|
+
onclick={() => selectStampAsset(asset)}
|
|
405
|
+
title={asset.id}
|
|
406
|
+
>
|
|
407
|
+
{#if asset.type === 'emoji'}
|
|
408
|
+
<span class="emoji">{asset.content}</span>
|
|
409
|
+
{:else}
|
|
410
|
+
<img src={asset.preview || asset.content} alt={asset.id} />
|
|
411
|
+
{/if}
|
|
412
|
+
</button>
|
|
413
|
+
{/each}
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<div
|
|
418
|
+
bind:this={overlayElement}
|
|
419
|
+
class="stamp-canvas-overlay"
|
|
420
|
+
onmousedown={handleCanvasMouseDown}
|
|
421
|
+
role="button"
|
|
422
|
+
tabindex="-1"
|
|
423
|
+
>
|
|
424
|
+
|
|
425
|
+
<!-- Render stamp selection boxes -->
|
|
426
|
+
{#if canvas}
|
|
427
|
+
<svg class="stamp-svg">
|
|
428
|
+
{#each canvasStampAreas as canvasStamp}
|
|
429
|
+
{@const isSelected = selectedStampId === canvasStamp.id}
|
|
430
|
+
{@const handles = getResizeHandles(canvasStamp)}
|
|
431
|
+
{@const rotHandle = getRotationHandlePosition(canvasStamp)}
|
|
432
|
+
|
|
433
|
+
<g transform="rotate({canvasStamp.rotation || 0} {canvasStamp.canvasCenterX} {canvasStamp.canvasCenterY})">
|
|
434
|
+
<rect
|
|
435
|
+
x={canvasStamp.canvasCenterX - canvasStamp.canvasWidth / 2}
|
|
436
|
+
y={canvasStamp.canvasCenterY - canvasStamp.canvasHeight / 2}
|
|
437
|
+
width={canvasStamp.canvasWidth}
|
|
438
|
+
height={canvasStamp.canvasHeight}
|
|
439
|
+
fill="none"
|
|
440
|
+
stroke={isSelected ? 'var(--primary-color, #63b97b)' : '#ffffff'}
|
|
441
|
+
stroke-width="2"
|
|
442
|
+
stroke-dasharray={isSelected ? '0' : '5,5'}
|
|
443
|
+
/>
|
|
444
|
+
</g>
|
|
445
|
+
|
|
446
|
+
{#if isSelected}
|
|
447
|
+
<!-- Resize handles (nw, ne, sw only - se is for rotation) -->
|
|
448
|
+
{#each ['nw', 'ne', 'sw'] as handleKey}
|
|
449
|
+
{@const handle = handles[handleKey]}
|
|
450
|
+
{@const cursor = handleKey === 'nw' ? 'nwse-resize' : 'nesw-resize'}
|
|
451
|
+
<circle
|
|
452
|
+
cx={handle.x}
|
|
453
|
+
cy={handle.y}
|
|
454
|
+
r="6"
|
|
455
|
+
fill="var(--primary-color, #63b97b)"
|
|
456
|
+
stroke="#fff"
|
|
457
|
+
stroke-width="2"
|
|
458
|
+
style="pointer-events: all; cursor: {cursor};"
|
|
459
|
+
/>
|
|
460
|
+
{/each}
|
|
461
|
+
|
|
462
|
+
<!-- Rotation handle (se corner) -->
|
|
463
|
+
<circle
|
|
464
|
+
cx={rotHandle.x}
|
|
465
|
+
cy={rotHandle.y}
|
|
466
|
+
r="8"
|
|
467
|
+
fill="#00cc00"
|
|
468
|
+
stroke="#fff"
|
|
469
|
+
stroke-width="2"
|
|
470
|
+
style="pointer-events: all; cursor: grab;"
|
|
471
|
+
/>
|
|
472
|
+
<g transform="translate({rotHandle.x}, {rotHandle.y})">
|
|
473
|
+
<foreignObject x="-8" y="-8" width="16" height="16" style="pointer-events: none;">
|
|
474
|
+
<div style="color: white; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center;">
|
|
475
|
+
<RotateCw size={12} />
|
|
476
|
+
</div>
|
|
477
|
+
</foreignObject>
|
|
478
|
+
</g>
|
|
479
|
+
{/if}
|
|
480
|
+
{/each}
|
|
481
|
+
</svg>
|
|
482
|
+
{/if}
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
<div class="stamp-controls">
|
|
486
|
+
{#if selectedStampId}
|
|
487
|
+
<button class="control-btn delete" onclick={handleDeleteStamp}>
|
|
488
|
+
<Trash2 size={16} />
|
|
489
|
+
<span>{$_('editor.delete')}</span>
|
|
490
|
+
</button>
|
|
491
|
+
{/if}
|
|
492
|
+
<button class="control-btn" onclick={onClose}>
|
|
493
|
+
{$_('editor.close')}
|
|
494
|
+
</button>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<style>
|
|
499
|
+
.stamp-tool {
|
|
500
|
+
position: absolute;
|
|
501
|
+
top: 0;
|
|
502
|
+
left: 0;
|
|
503
|
+
width: 100%;
|
|
504
|
+
height: 100%;
|
|
505
|
+
pointer-events: none;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@media (max-width: 767px) {
|
|
509
|
+
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.stamp-palette {
|
|
513
|
+
position: absolute;
|
|
514
|
+
top: 1rem;
|
|
515
|
+
right: 1rem;
|
|
516
|
+
background: rgba(30, 30, 30, 0.95);
|
|
517
|
+
border: 1px solid #444;
|
|
518
|
+
border-radius: 8px;
|
|
519
|
+
padding: 1rem;
|
|
520
|
+
width: 280px;
|
|
521
|
+
max-height: 400px;
|
|
522
|
+
overflow-y: auto;
|
|
523
|
+
pointer-events: all;
|
|
524
|
+
backdrop-filter: blur(10px);
|
|
525
|
+
z-index: 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
@media (max-width: 767px) {
|
|
529
|
+
|
|
530
|
+
.stamp-palette {
|
|
531
|
+
top: auto;
|
|
532
|
+
bottom: 0;
|
|
533
|
+
right: 0;
|
|
534
|
+
left: 0;
|
|
535
|
+
width: auto
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.stamp-palette h3 {
|
|
540
|
+
margin: 0 0 1rem 0;
|
|
541
|
+
font-size: 1rem;
|
|
542
|
+
color: #fff;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
@media (max-width: 767px) {
|
|
546
|
+
|
|
547
|
+
.stamp-palette h3 {
|
|
548
|
+
display: none
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.stamp-grid {
|
|
553
|
+
display: grid;
|
|
554
|
+
grid-template-columns: repeat(4, 1fr);
|
|
555
|
+
gap: 0.5rem;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
@media (max-width: 767px) {
|
|
559
|
+
|
|
560
|
+
.stamp-grid {
|
|
561
|
+
display: flex
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.stamp-item {
|
|
566
|
+
width: 60px;
|
|
567
|
+
height: 60px;
|
|
568
|
+
background: #333;
|
|
569
|
+
border: 2px solid #444;
|
|
570
|
+
border-radius: 4px;
|
|
571
|
+
cursor: pointer;
|
|
572
|
+
transition: all 0.2s;
|
|
573
|
+
display: flex;
|
|
574
|
+
align-items: center;
|
|
575
|
+
justify-content: center;
|
|
576
|
+
padding: 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
@media (max-width: 767px) {
|
|
580
|
+
|
|
581
|
+
.stamp-item {
|
|
582
|
+
flex-shrink: 0
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.stamp-item:hover {
|
|
587
|
+
background: #444;
|
|
588
|
+
border-color: #666;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.stamp-item.selected {
|
|
592
|
+
background: var(--primary-color, #63b97b);
|
|
593
|
+
border-color: var(--primary-color, #63b97b);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.stamp-item .emoji {
|
|
597
|
+
font-size: 2rem;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.stamp-item img {
|
|
601
|
+
max-width: 90%;
|
|
602
|
+
max-height: 90%;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.stamp-canvas-overlay {
|
|
606
|
+
position: absolute;
|
|
607
|
+
top: 0;
|
|
608
|
+
left: 0;
|
|
609
|
+
width: 100%;
|
|
610
|
+
height: 100%;
|
|
611
|
+
pointer-events: all;
|
|
612
|
+
user-select: none;
|
|
613
|
+
cursor: grab;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.stamp-canvas-overlay:active {
|
|
617
|
+
cursor: grabbing;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.stamp-svg {
|
|
621
|
+
width: 100%;
|
|
622
|
+
height: 100%;
|
|
623
|
+
pointer-events: none;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.stamp-svg circle,
|
|
627
|
+
.stamp-svg rect {
|
|
628
|
+
pointer-events: all;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.stamp-controls {
|
|
632
|
+
position: absolute;
|
|
633
|
+
bottom: 20px;
|
|
634
|
+
right: 20px;
|
|
635
|
+
display: flex;
|
|
636
|
+
gap: 0.5rem;
|
|
637
|
+
pointer-events: all;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.control-btn {
|
|
641
|
+
display: flex;
|
|
642
|
+
align-items: center;
|
|
643
|
+
gap: 0.5rem;
|
|
644
|
+
padding: 0.5rem 1rem;
|
|
645
|
+
background: #333;
|
|
646
|
+
color: #fff;
|
|
647
|
+
border: 1px solid #444;
|
|
648
|
+
border-radius: 4px;
|
|
649
|
+
cursor: pointer;
|
|
650
|
+
transition: all 0.2s;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.control-btn:hover {
|
|
654
|
+
background: #444;
|
|
655
|
+
border-color: #555;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.control-btn.delete {
|
|
659
|
+
background: #cc0000;
|
|
660
|
+
border-color: #dd0000;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.control-btn.delete:hover {
|
|
664
|
+
background: #dd0000;
|
|
665
|
+
border-color: #ee0000;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/* Larger touch targets for mobile */
|
|
669
|
+
@media (max-width: 767px) {
|
|
670
|
+
.stamp-svg circle {
|
|
671
|
+
r: 12 !important;
|
|
672
|
+
stroke-width: 3 !important;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.stamp-svg circle[fill="#00cc00"] {
|
|
676
|
+
r: 14 !important;
|
|
677
|
+
}
|
|
678
|
+
}</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StampArea, Viewport, TransformState, CropArea } from '../types';
|
|
2
|
+
interface Props {
|
|
3
|
+
canvas: HTMLCanvasElement | null;
|
|
4
|
+
image: HTMLImageElement | null;
|
|
5
|
+
viewport: Viewport;
|
|
6
|
+
transform: TransformState;
|
|
7
|
+
stampAreas: StampArea[];
|
|
8
|
+
cropArea?: CropArea | null;
|
|
9
|
+
onUpdate: (stampAreas: StampArea[]) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
onViewportChange?: (viewport: Partial<Viewport>) => void;
|
|
12
|
+
}
|
|
13
|
+
declare const StampTool: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type StampTool = ReturnType<typeof StampTool>;
|
|
15
|
+
export default StampTool;
|