@elixpo/lixsketch 4.5.8

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/fonts/fonts.css +29 -0
  4. package/fonts/lixCode.ttf +0 -0
  5. package/fonts/lixDefault.ttf +0 -0
  6. package/fonts/lixDocs.ttf +0 -0
  7. package/fonts/lixFancy.ttf +0 -0
  8. package/fonts/lixFont.woff2 +0 -0
  9. package/package.json +49 -0
  10. package/src/SketchEngine.js +473 -0
  11. package/src/core/AIRenderer.js +1390 -0
  12. package/src/core/CopyPaste.js +655 -0
  13. package/src/core/EraserTrail.js +234 -0
  14. package/src/core/EventDispatcher.js +371 -0
  15. package/src/core/GraphEngine.js +150 -0
  16. package/src/core/GraphMathParser.js +231 -0
  17. package/src/core/GraphRenderer.js +255 -0
  18. package/src/core/LayerOrder.js +91 -0
  19. package/src/core/LixScriptParser.js +1299 -0
  20. package/src/core/MermaidFlowchartRenderer.js +475 -0
  21. package/src/core/MermaidSequenceParser.js +197 -0
  22. package/src/core/MermaidSequenceRenderer.js +479 -0
  23. package/src/core/ResizeCode.js +175 -0
  24. package/src/core/ResizeShapes.js +318 -0
  25. package/src/core/SceneSerializer.js +778 -0
  26. package/src/core/Selection.js +1861 -0
  27. package/src/core/SnapGuides.js +273 -0
  28. package/src/core/UndoRedo.js +1358 -0
  29. package/src/core/ZoomPan.js +258 -0
  30. package/src/core/ai-system-prompt.js +663 -0
  31. package/src/index.js +69 -0
  32. package/src/shapes/Arrow.js +1979 -0
  33. package/src/shapes/Circle.js +751 -0
  34. package/src/shapes/CodeShape.js +244 -0
  35. package/src/shapes/Frame.js +1460 -0
  36. package/src/shapes/FreehandStroke.js +724 -0
  37. package/src/shapes/IconShape.js +265 -0
  38. package/src/shapes/ImageShape.js +270 -0
  39. package/src/shapes/Line.js +738 -0
  40. package/src/shapes/Rectangle.js +794 -0
  41. package/src/shapes/TextShape.js +225 -0
  42. package/src/tools/arrowTool.js +581 -0
  43. package/src/tools/circleTool.js +619 -0
  44. package/src/tools/codeTool.js +2103 -0
  45. package/src/tools/eraserTool.js +131 -0
  46. package/src/tools/frameTool.js +241 -0
  47. package/src/tools/freehandTool.js +620 -0
  48. package/src/tools/iconTool.js +1344 -0
  49. package/src/tools/imageTool.js +1323 -0
  50. package/src/tools/laserTool.js +317 -0
  51. package/src/tools/lineTool.js +502 -0
  52. package/src/tools/rectangleTool.js +544 -0
  53. package/src/tools/textTool.js +1823 -0
  54. package/src/utils/imageCompressor.js +107 -0
@@ -0,0 +1,1323 @@
1
+ /* eslint-disable */
2
+ // Image tool event handlers - extracted from imageTool.js
3
+ import { pushCreateAction, pushDeleteAction, pushTransformAction, pushFrameAttachmentAction } from '../core/UndoRedo.js';
4
+ import { updateAttachedArrows as updateArrowsForShape, cleanupAttachments } from './arrowTool.js';
5
+ import { compressImage } from '../utils/imageCompressor.js';
6
+
7
+
8
+ let isDraggingImage = false;
9
+ let imageToPlace = null;
10
+ let imageX = 0;
11
+ let imageY = 0;
12
+ let scaleFactor = 0.2;
13
+ let currentImageElement = null;
14
+
15
+ let selectedImage = null;
16
+ let originalX, originalY, originalWidth, originalHeight;
17
+ let currentAnchor = null;
18
+ let isDragging = false;
19
+ let isRotatingImage = false;
20
+ let dragOffsetX, dragOffsetY;
21
+ let startRotationMouseAngle = null;
22
+ let startImageRotation = null;
23
+ let imageRotation = 0;
24
+ let aspect_ratio_lock = true;
25
+ const minImageSize = 20;
26
+
27
+ // Frame attachment variables
28
+ let draggedShapeInitialFrameImage = null;
29
+ let hoveredFrameImage = null;
30
+
31
+ // Per-room image size limit: 5MB total
32
+ const ROOM_IMAGE_LIMIT_BYTES = 5 * 1024 * 1024;
33
+ if (!window.__roomImageBytesUsed) window.__roomImageBytesUsed = 0;
34
+
35
+
36
+ // Convert SVG element to our ImageShape class
37
+ function wrapImageElement(element) {
38
+ const imageShape = new ImageShape(element);
39
+ return imageShape;
40
+ }
41
+
42
+ /**
43
+ * Async image upload pipeline:
44
+ * 1. Show loading indicator on the image
45
+ * 2. Compress the image adaptively
46
+ * 3. Get a signed upload URL from the worker
47
+ * 4. Upload compressed image to Cloudinary
48
+ * 5. Replace the base64 href with Cloudinary URL
49
+ * 6. Remove loading indicator
50
+ */
51
+ async function uploadImageToCloudinary(imageShape) {
52
+ const workerUrl = window.__WORKER_URL;
53
+ const sessionId = window.__sessionID;
54
+ if (!workerUrl || !sessionId) return;
55
+
56
+ const href = imageShape.element.getAttribute('href') || '';
57
+ if (!href.startsWith('data:')) return; // already a URL, skip
58
+
59
+ imageShape.uploadStatus = 'uploading';
60
+ imageShape.uploadAbortController = new AbortController();
61
+ const signal = imageShape.uploadAbortController.signal;
62
+
63
+ imageShape.showUploadIndicator();
64
+
65
+ try {
66
+ // Step 1: Compress
67
+ const compressed = await compressImage(href);
68
+ if (signal.aborted) return;
69
+
70
+ // Step 2: Get signed upload params
71
+ const signRes = await fetch(`${workerUrl}/api/images/sign`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({
75
+ sessionId,
76
+ filename: `img_${Date.now()}`,
77
+ }),
78
+ signal,
79
+ });
80
+ if (!signRes.ok) throw new Error('Failed to get upload signature');
81
+ const signData = await signRes.json();
82
+ if (signal.aborted) return;
83
+
84
+ // Step 3: Upload to Cloudinary
85
+ const formData = new FormData();
86
+ formData.append('file', compressed.blob);
87
+ formData.append('api_key', signData.apiKey);
88
+ formData.append('timestamp', String(signData.timestamp));
89
+ formData.append('signature', signData.signature);
90
+ formData.append('folder', signData.folder);
91
+ formData.append('public_id', signData.publicId);
92
+
93
+ const uploadRes = await fetch(
94
+ `https://api.cloudinary.com/v1_1/${signData.cloudName}/image/upload`,
95
+ { method: 'POST', body: formData, signal }
96
+ );
97
+ if (!uploadRes.ok) throw new Error('Cloudinary upload failed');
98
+ const uploadData = await uploadRes.json();
99
+ if (signal.aborted) return;
100
+
101
+ // Step 4: Replace base64 with Cloudinary URL
102
+ const cloudUrl = uploadData.secure_url || uploadData.url;
103
+ imageShape.element.setAttribute('href', cloudUrl);
104
+ imageShape.element.setAttribute('data-href', cloudUrl);
105
+ imageShape.element.setAttribute('data-cloudinary-id', uploadData.public_id);
106
+
107
+ // Update file size tracking with actual compressed size
108
+ const oldSize = imageShape.element.__fileSize || 0;
109
+ const newSize = uploadData.bytes || compressed.compressedSize;
110
+ imageShape.element.__fileSize = newSize;
111
+ window.__roomImageBytesUsed = Math.max(0, (window.__roomImageBytesUsed || 0) - oldSize + newSize);
112
+
113
+ imageShape.uploadStatus = 'done';
114
+ console.log(`[ImageUpload] Uploaded to Cloudinary: ${cloudUrl} (${(newSize / 1024).toFixed(1)}KB)`);
115
+ } catch (err) {
116
+ if (signal.aborted) {
117
+ console.log('[ImageUpload] Upload aborted (image deleted)');
118
+ return;
119
+ }
120
+ console.warn('[ImageUpload] Upload failed:', err);
121
+ imageShape.uploadStatus = 'failed';
122
+ } finally {
123
+ imageShape.removeUploadIndicator();
124
+ imageShape.uploadAbortController = null;
125
+ }
126
+ }
127
+
128
+ document.getElementById("importImage")?.addEventListener('click', () => {
129
+ console.log('Import image clicked');
130
+ isImageToolActive = true;
131
+ console.log('isImageToolActive set to:', isImageToolActive);
132
+
133
+ // Create a file input element
134
+ const fileInput = document.createElement('input');
135
+ fileInput.type = 'file';
136
+ fileInput.accept = 'image/*'; // Accept all image types
137
+ fileInput.style.display = 'none'; // Hide the input element
138
+
139
+ // Add the input to the document temporarily
140
+ document.body.appendChild(fileInput);
141
+
142
+ let fileSelected = false;
143
+
144
+ // Handle file selection
145
+ fileInput.addEventListener('change', (event) => {
146
+ const file = event.target.files[0];
147
+ if (file) {
148
+ fileSelected = true;
149
+ handleImageUpload(file);
150
+ }
151
+ document.body.removeChild(fileInput);
152
+ });
153
+
154
+ // Detect cancel
155
+ const onFocus = () => {
156
+ window.removeEventListener('focus', onFocus);
157
+ setTimeout(() => {
158
+ if (!fileSelected) {
159
+ isImageToolActive = false;
160
+ if (window.__sketchEngine?.setActiveTool) {
161
+ window.__sketchEngine.setActiveTool('select');
162
+ }
163
+ if (fileInput.parentNode) {
164
+ document.body.removeChild(fileInput);
165
+ }
166
+ }
167
+ }, 300);
168
+ };
169
+ window.addEventListener('focus', onFocus);
170
+
171
+ // Trigger the file picker
172
+ fileInput.click();
173
+ });
174
+
175
+
176
+ const loadHardcodedImage = (imagePath) => {
177
+ console.log('Loading hardcoded image:', imagePath);
178
+ const img = new Image();
179
+ img.onload = () => {
180
+ console.log('Image loaded successfully');
181
+ const canvas = document.createElement('canvas');
182
+ const ctx = canvas.getContext('2d');
183
+ canvas.width = img.width;
184
+ canvas.height = img.height;
185
+ ctx.drawImage(img, 0, 0);
186
+ imageToPlace = canvas.toDataURL();
187
+ isDraggingImage = true;
188
+ console.log('Image ready to place, isDraggingImage:', isDraggingImage);
189
+ };
190
+ img.onerror = (error) => {
191
+ console.error('Failed to load the hardcoded image:', error);
192
+ console.error('Image path:', imagePath);
193
+ };
194
+ img.src = imagePath;
195
+ };
196
+
197
+ const handleImageUpload = (file) => {
198
+ if (!file || !isImageToolActive) return;
199
+
200
+ // Validate file type
201
+ if (!file.type.startsWith('image/')) {
202
+ console.error('Selected file is not an image');
203
+ alert('Please select a valid image file.');
204
+ return;
205
+ }
206
+
207
+ // Per-room 5MB total image limit
208
+ if (window.__roomImageBytesUsed + file.size > ROOM_IMAGE_LIMIT_BYTES) {
209
+ const usedMB = (window.__roomImageBytesUsed / (1024 * 1024)).toFixed(2);
210
+ const fileMB = (file.size / (1024 * 1024)).toFixed(2);
211
+ alert(`Room image limit reached (5 MB). Used: ${usedMB} MB, this file: ${fileMB} MB. Delete some images to free space.`);
212
+ isImageToolActive = false;
213
+ return;
214
+ }
215
+
216
+ const maxSize = 5 * 1024 * 1024; // 5MB per file (matches room limit)
217
+ if (file.size > maxSize) {
218
+ console.error('File size too large');
219
+ alert('Image file is too large. Please select an image smaller than 5 MB.');
220
+ return;
221
+ }
222
+
223
+ console.log('Processing image file:', file.name, 'Size:', file.size, 'Type:', file.type);
224
+
225
+ const reader = new FileReader();
226
+
227
+ // Store file size for room limit tracking
228
+ window.__pendingImageFileSize = file.size;
229
+
230
+ reader.onload = (e) => {
231
+ imageToPlace = e.target.result;
232
+ isDraggingImage = true;
233
+ console.log('Image loaded and ready to place');
234
+ };
235
+
236
+ reader.onerror = (error) => {
237
+ console.error('Error reading file:', error);
238
+ alert('Error reading the image file. Please try again.');
239
+ isImageToolActive = false;
240
+ };
241
+
242
+ reader.readAsDataURL(file);
243
+ };
244
+
245
+ // Add coordinate conversion function like in other tools
246
+ function getSVGCoordsFromMouse(e) {
247
+ const viewBox = svg.viewBox.baseVal;
248
+ const rect = svg.getBoundingClientRect();
249
+ const mouseX = e.clientX - rect.left;
250
+ const mouseY = e.clientY - rect.top;
251
+ const svgX = viewBox.x + (mouseX / rect.width) * viewBox.width;
252
+ const svgY = viewBox.y + (mouseY / rect.height) * viewBox.height;
253
+ return { x: svgX, y: svgY };
254
+ }
255
+
256
+ // Event handler for mousemove on the SVG
257
+ const handleMouseMoveImage = (e) => {
258
+ if (!isDraggingImage || !imageToPlace || !isImageToolActive) return; // Also check isImageToolActive
259
+
260
+ // Get mouse coordinates relative to the SVG element
261
+ const { x, y } = getSVGCoordsFromMouse(e);
262
+ imageX = x;
263
+ imageY = y;
264
+
265
+ drawMiniatureImage();
266
+
267
+ // Check for frame containment while placing image (but don't apply clipping yet)
268
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
269
+ shapes.forEach(frame => {
270
+ if (frame.shapeName === 'frame') {
271
+ // Create temporary image bounds for frame checking
272
+ const tempImageBounds = {
273
+ x: imageX - 50, // Half of miniature width
274
+ y: imageY - 50, // Approximate half height
275
+ width: 100,
276
+ height: 100
277
+ };
278
+
279
+ if (frame.isShapeInFrame(tempImageBounds)) {
280
+ frame.highlightFrame();
281
+ hoveredFrameImage = frame;
282
+ } else if (hoveredFrameImage === frame) {
283
+ frame.removeHighlight();
284
+ hoveredFrameImage = null;
285
+ }
286
+ }
287
+ });
288
+ }
289
+ };
290
+
291
+ const drawMiniatureImage = () => {
292
+ if (!isDraggingImage || !imageToPlace || !isImageToolActive) return; // Also check isImageToolActive
293
+
294
+ const miniatureWidth = 100; // Fixed width for miniature (adjust as needed)
295
+ getImageAspectRatio(imageToPlace)
296
+ .then(aspectRatio => {
297
+ const miniatureHeight = miniatureWidth * aspectRatio; // Maintain aspect ratio
298
+
299
+ // Remove the previous miniature image, if it exists
300
+ if (currentImageElement) {
301
+ svg.removeChild(currentImageElement);
302
+ currentImageElement = null; // Important: clear the reference
303
+ }
304
+
305
+ // Create an SVG image element for the miniature
306
+ currentImageElement = document.createElementNS("http://www.w3.org/2000/svg", "image");
307
+ currentImageElement.setAttribute("href", imageToPlace);
308
+ currentImageElement.setAttribute("x", imageX - miniatureWidth / 2);
309
+ currentImageElement.setAttribute("y", imageY - miniatureHeight / 2);
310
+ currentImageElement.setAttribute("width", miniatureWidth);
311
+ currentImageElement.setAttribute("height", miniatureHeight);
312
+ currentImageElement.setAttribute("preserveAspectRatio", "xMidYMid meet");
313
+
314
+ svg.appendChild(currentImageElement);
315
+ })
316
+ .catch(error => {
317
+ console.error("Error getting aspect ratio:", error);
318
+
319
+ });
320
+ };
321
+
322
+ function getImageAspectRatio(dataUrl) {
323
+ return new Promise((resolve, reject) => {
324
+ const img = new Image();
325
+ img.onload = () => {
326
+ resolve(img.height / img.width);
327
+ };
328
+ img.onerror = () => {
329
+ reject(new Error('Failed to load image for aspect ratio calculation.'));
330
+ };
331
+ img.src = dataUrl;
332
+ });
333
+ }
334
+
335
+ // Update the handleMouseDownImage function to create proper group structure
336
+ const handleMouseDownImage = async (e) => {
337
+ if (!isDraggingImage || !imageToPlace || !isImageToolActive) {
338
+ // Handle image selection if selection tool is active
339
+ if (isSelectionToolActive) {
340
+ const clickedImage = e.target.closest('image');
341
+ if (clickedImage) {
342
+ selectImage({ target: clickedImage, stopPropagation: () => e.stopPropagation() });
343
+ return;
344
+ }
345
+ }
346
+ return;
347
+ }
348
+
349
+ try {
350
+ //Get aspect ratio before we clear the temporary image data.
351
+ let aspectRatio = await getImageAspectRatio(imageToPlace);
352
+
353
+ // Remove the miniature
354
+ if (currentImageElement) {
355
+ svg.removeChild(currentImageElement);
356
+ currentImageElement = null;
357
+ }
358
+
359
+ // Calculate actual dimensions of the placed image
360
+ const placedImageWidth = 200; // Adjust as needed
361
+ const placedImageHeight = placedImageWidth * aspectRatio;
362
+
363
+ // Get SVG coordinates
364
+ const { x: placedX, y: placedY } = getSVGCoordsFromMouse(e);
365
+
366
+ // Create a new SVG image element for the final image
367
+ const finalImage = document.createElementNS("http://www.w3.org/2000/svg", "image");
368
+ finalImage.setAttribute("href", imageToPlace);
369
+ finalImage.setAttribute("x", placedX - placedImageWidth / 2);
370
+ finalImage.setAttribute("y", placedY - placedImageHeight / 2);
371
+ finalImage.setAttribute("width", placedImageWidth);
372
+ finalImage.setAttribute("height", placedImageHeight);
373
+ finalImage.setAttribute("preserveAspectRatio", "xMidYMid meet");
374
+
375
+ // Store data for undo/redo
376
+ finalImage.setAttribute('data-href', imageToPlace);
377
+ finalImage.setAttribute('data-x', placedX - placedImageWidth / 2);
378
+ finalImage.setAttribute('data-y', placedY - placedImageHeight / 2);
379
+ finalImage.setAttribute('data-width', placedImageWidth);
380
+ finalImage.setAttribute('data-height', placedImageHeight);
381
+
382
+ // Add arrow attachment support data attributes
383
+ finalImage.setAttribute('type', 'image');
384
+ finalImage.setAttribute('data-shape-x', placedX - placedImageWidth / 2);
385
+ finalImage.setAttribute('data-shape-y', placedY - placedImageHeight / 2);
386
+ finalImage.setAttribute('data-shape-width', placedImageWidth);
387
+ finalImage.setAttribute('data-shape-height', placedImageHeight);
388
+ finalImage.setAttribute('data-shape-rotation', 0);
389
+ finalImage.shapeID = `image-${String(Date.now()).slice(0, 8)}-${Math.floor(Math.random() * 10000)}`;
390
+
391
+ // Don't add to SVG directly - let ImageShape wrapper handle it
392
+ // svg.appendChild(finalImage);
393
+
394
+ // Create ImageShape wrapper for frame functionality
395
+ const imageShape = wrapImageElement(finalImage);
396
+
397
+ // Add to shapes array for arrow attachment and frame functionality
398
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
399
+ shapes.push(imageShape);
400
+ console.log('Image added to shapes array for arrow attachment and frame functionality');
401
+ } else {
402
+ console.warn('shapes array not found - arrows and frames may not work with images');
403
+ }
404
+
405
+ // Check for frame containment and track attachment
406
+ const finalFrame = hoveredFrameImage;
407
+ if (finalFrame) {
408
+ finalFrame.addShapeToFrame(imageShape);
409
+ // Track the attachment for undo
410
+ pushFrameAttachmentAction(finalFrame, imageShape, 'attach', null);
411
+ }
412
+
413
+ // Add to undo stack for image creation
414
+ pushCreateAction({
415
+ type: 'image',
416
+ element: imageShape,
417
+ remove: () => {
418
+ if (imageShape.group && imageShape.group.parentNode) {
419
+ imageShape.group.parentNode.removeChild(imageShape.group);
420
+ }
421
+ // Remove from shapes array
422
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
423
+ const idx = shapes.indexOf(imageShape);
424
+ if (idx !== -1) shapes.splice(idx, 1);
425
+ }
426
+ // Clean up arrow attachments when image is removed
427
+ if (typeof cleanupAttachments === 'function') {
428
+ cleanupAttachments(finalImage);
429
+ }
430
+ },
431
+ restore: () => {
432
+ svg.appendChild(imageShape.group);
433
+ // Add back to shapes array
434
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
435
+ if (shapes.indexOf(imageShape) === -1) {
436
+ shapes.push(imageShape);
437
+ }
438
+ }
439
+ }
440
+ });
441
+
442
+ // Track image size for room limit
443
+ const placedFileSize = window.__pendingImageFileSize || 0;
444
+ finalImage.__fileSize = placedFileSize;
445
+ window.__roomImageBytesUsed = (window.__roomImageBytesUsed || 0) + placedFileSize;
446
+ window.__pendingImageFileSize = 0;
447
+
448
+ // Add click event to the newly added image
449
+ finalImage.addEventListener('click', selectImage);
450
+
451
+ // Clear frame highlighting
452
+ if (hoveredFrameImage) {
453
+ hoveredFrameImage.removeHighlight();
454
+ hoveredFrameImage = null;
455
+ }
456
+
457
+ // Auto-select the placed image and switch to selection tool
458
+ const placedShape = imageShape;
459
+ if (window.__sketchStoreApi) window.__sketchStoreApi.setActiveTool('select', { afterDraw: true });
460
+ currentShape = placedShape;
461
+ currentShape.isSelected = true;
462
+ placedShape.selectShape();
463
+
464
+ // Fire async upload pipeline (compress + upload to Cloudinary)
465
+ uploadImageToCloudinary(imageShape).catch(err => {
466
+ console.warn('[ImageTool] Upload pipeline error:', err);
467
+ });
468
+
469
+ } catch (error) {
470
+ console.error("Error placing image:", error);
471
+ isDraggingImage = false;
472
+ imageToPlace = null;
473
+ isImageToolActive = false; // Important: Reset the tool state.
474
+ } finally {
475
+ isDraggingImage = false;
476
+ imageToPlace = null;
477
+ isImageToolActive = false; // Important: Reset the tool state.
478
+ }
479
+ };
480
+
481
+ const handleMouseUpImage = (e) => {
482
+ // Only deselect if the user actually clicked (mousedown+mouseup) on a non-image area
483
+ // of the SVG canvas — not when the mouse just leaves to the UI
484
+ if (isSelectionToolActive && selectedImage) {
485
+ const clickedElement = e.target;
486
+ const isOnSVG = clickedElement === svg || clickedElement.ownerSVGElement === svg;
487
+ const isImageElement = clickedElement.tagName === 'image';
488
+ const isAnchorElement = clickedElement.classList.contains('resize-anchor') ||
489
+ clickedElement.classList.contains('rotation-anchor') ||
490
+ clickedElement.classList.contains('selection-outline');
491
+
492
+ // Only deselect if mouseup is directly on the SVG background (not on any shape/anchor)
493
+ if (isOnSVG && !isImageElement && !isAnchorElement && clickedElement === svg && !isDragging && !isRotatingImage && !currentAnchor) {
494
+ removeSelectionOutline();
495
+ selectedImage = null;
496
+ if (window.__sketchStoreApi) {
497
+ window.__sketchStoreApi.clearSelectedShapeSidebar();
498
+ }
499
+ }
500
+ }
501
+
502
+ // Clear frame highlighting if placing image
503
+ if (hoveredFrameImage) {
504
+ hoveredFrameImage.removeHighlight();
505
+ hoveredFrameImage = null;
506
+ }
507
+ };
508
+
509
+ function selectImage(event) {
510
+ if (!isSelectionToolActive) return;
511
+
512
+ event.stopPropagation(); // Prevent click from propagating to the SVG
513
+
514
+ if (selectedImage) {
515
+ removeSelectionOutline();
516
+ }
517
+
518
+ selectedImage = event.target;
519
+
520
+ // Get the current rotation from the image transform attribute
521
+ const transform = selectedImage.getAttribute('transform');
522
+ if (transform) {
523
+ const rotateMatch = transform.match(/rotate\(([^,]+)/);
524
+ if (rotateMatch) {
525
+ imageRotation = parseFloat(rotateMatch[1]);
526
+ }
527
+ } else {
528
+ imageRotation = 0;
529
+ }
530
+
531
+ addSelectionOutline();
532
+
533
+ // Store original dimensions for resizing
534
+ originalX = parseFloat(selectedImage.getAttribute('x'));
535
+ originalY = parseFloat(selectedImage.getAttribute('y'));
536
+ originalWidth = parseFloat(selectedImage.getAttribute('width'));
537
+ originalHeight = parseFloat(selectedImage.getAttribute('height'));
538
+
539
+ // Add drag event listeners to the selected image
540
+ selectedImage.addEventListener('mousedown', startDrag);
541
+ selectedImage.addEventListener('mouseup', stopDrag);
542
+ selectedImage.addEventListener('mouseleave', stopDrag);
543
+
544
+ // Set currentShape for sidebar + layer controls
545
+ const imageShape = (typeof shapes !== 'undefined' && Array.isArray(shapes))
546
+ ? shapes.find(s => s.shapeName === 'image' && s.element === selectedImage)
547
+ : null;
548
+ if (imageShape) {
549
+ window.currentShape = imageShape;
550
+ }
551
+ if (typeof window.__showSidebarForShape === 'function') {
552
+ window.__showSidebarForShape('image');
553
+ }
554
+ }
555
+
556
+ function addSelectionOutline() {
557
+ if (!selectedImage) return;
558
+
559
+ const x = parseFloat(selectedImage.getAttribute('x'));
560
+ const y = parseFloat(selectedImage.getAttribute('y'));
561
+ const width = parseFloat(selectedImage.getAttribute('width'));
562
+ const height = parseFloat(selectedImage.getAttribute('height'));
563
+
564
+ const selectionPadding = 8; // Padding around the selection
565
+ const expandedX = x - selectionPadding;
566
+ const expandedY = y - selectionPadding;
567
+ const expandedWidth = width + 2 * selectionPadding;
568
+ const expandedHeight = height + 2 * selectionPadding;
569
+
570
+ // Create a dashed outline
571
+ const outlinePoints = [
572
+ [expandedX, expandedY],
573
+ [expandedX + expandedWidth, expandedY],
574
+ [expandedX + expandedWidth, expandedY + expandedHeight],
575
+ [expandedX, expandedY + expandedHeight],
576
+ [expandedX, expandedY]
577
+ ];
578
+ const pointsAttr = outlinePoints.map(p => p.join(',')).join(' ');
579
+ const outline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
580
+ outline.setAttribute("points", pointsAttr);
581
+ outline.setAttribute("fill", "none");
582
+ outline.setAttribute("stroke", "#5B57D1");
583
+ outline.setAttribute("stroke-width", 1.5);
584
+ outline.setAttribute("stroke-dasharray", "4 2");
585
+ outline.setAttribute("style", "pointer-events: none;");
586
+ outline.setAttribute("class", "selection-outline");
587
+
588
+ // Apply the same rotation as the image
589
+ const centerX = x + width / 2;
590
+ const centerY = y + height / 2;
591
+ outline.setAttribute('transform', `rotate(${imageRotation}, ${centerX}, ${centerY})`);
592
+
593
+ svg.appendChild(outline);
594
+
595
+ // Add resize anchors
596
+ addResizeAnchors(expandedX, expandedY, expandedWidth, expandedHeight, centerX, centerY);
597
+
598
+ // Add rotation anchor
599
+ addRotationAnchor(expandedX, expandedY, expandedWidth, expandedHeight, centerX, centerY);
600
+ }
601
+
602
+ function removeSelectionOutline() {
603
+ // Remove ALL selection outlines (querySelectorAll to prevent stacking)
604
+ svg.querySelectorAll(".selection-outline").forEach(el => el.remove());
605
+
606
+ // Remove resize anchors
607
+ removeResizeAnchors();
608
+
609
+ // Remove rotation anchor
610
+ removeRotationAnchor();
611
+
612
+ // Remove drag event listeners
613
+ if (selectedImage) {
614
+ selectedImage.removeEventListener('mousedown', startDrag);
615
+ selectedImage.removeEventListener('mouseup', stopDrag);
616
+ selectedImage.removeEventListener('mouseleave', stopDrag);
617
+ }
618
+ }
619
+
620
+ function addResizeAnchors(x, y, width, height, centerX, centerY) {
621
+ const zoom = window.currentZoom || 1;
622
+ const anchorSize = 10 / zoom;
623
+ const anchorStrokeWidth = 2;
624
+
625
+ const positions = [
626
+ { x: x, y: y }, // Top-left
627
+ { x: x + width, y: y }, // Top-right
628
+ { x: x, y: y + height }, // Bottom-left
629
+ { x: x + width, y: y + height } // Bottom-right
630
+ ];
631
+
632
+ positions.forEach((pos, i) => {
633
+ const anchor = document.createElementNS("http://www.w3.org/2000/svg", "rect");
634
+ anchor.setAttribute("x", pos.x - anchorSize / 2);
635
+ anchor.setAttribute("y", pos.y - anchorSize / 2);
636
+ anchor.setAttribute("width", anchorSize);
637
+ anchor.setAttribute("height", anchorSize);
638
+ anchor.setAttribute("fill", "#121212");
639
+ anchor.setAttribute("stroke", "#5B57D1");
640
+ anchor.setAttribute("stroke-width", anchorStrokeWidth);
641
+ anchor.setAttribute("class", "resize-anchor");
642
+ anchor.style.cursor = ["nw-resize", "ne-resize", "sw-resize", "se-resize"][i];
643
+
644
+ // Apply the same rotation as the image
645
+ anchor.setAttribute('transform', `rotate(${imageRotation}, ${centerX}, ${centerY})`);
646
+
647
+ svg.appendChild(anchor);
648
+
649
+ // Add event listeners for resizing
650
+ anchor.addEventListener('mousedown', startResize);
651
+ anchor.addEventListener('mouseup', stopResize);
652
+ });
653
+ }
654
+
655
+ function addRotationAnchor(x, y, width, height, centerX, centerY) {
656
+ const anchorStrokeWidth = 2;
657
+ const rotationAnchorPos = { x: x + width / 2, y: y - 30 };
658
+
659
+ const rotationAnchor = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
660
+ rotationAnchor.setAttribute('cx', rotationAnchorPos.x);
661
+ rotationAnchor.setAttribute('cy', rotationAnchorPos.y);
662
+ rotationAnchor.setAttribute('r', 8);
663
+ rotationAnchor.setAttribute('class', 'rotation-anchor');
664
+ rotationAnchor.setAttribute('fill', '#121212');
665
+ rotationAnchor.setAttribute('stroke', '#5B57D1');
666
+ rotationAnchor.setAttribute('stroke-width', anchorStrokeWidth);
667
+ rotationAnchor.setAttribute('style', 'pointer-events: all;');
668
+
669
+ // Apply the same rotation as the image
670
+ rotationAnchor.setAttribute('transform', `rotate(${imageRotation}, ${centerX}, ${centerY})`);
671
+
672
+ svg.appendChild(rotationAnchor);
673
+
674
+ // Add event listeners for rotation
675
+ rotationAnchor.addEventListener('mousedown', startRotation);
676
+ rotationAnchor.addEventListener('mouseup', stopRotation);
677
+
678
+ rotationAnchor.addEventListener('mouseover', function () {
679
+ if (!isRotatingImage && !isDragging) {
680
+ rotationAnchor.style.cursor = 'grab';
681
+ }
682
+ });
683
+
684
+ rotationAnchor.addEventListener('mouseout', function () {
685
+ if (!isRotatingImage && !isDragging) {
686
+ rotationAnchor.style.cursor = 'default';
687
+ }
688
+ });
689
+ }
690
+
691
+ function removeRotationAnchor() {
692
+ svg.querySelectorAll(".rotation-anchor").forEach(el => el.remove());
693
+ }
694
+
695
+ function addAnchor(x, y, cursor) {
696
+ const anchorSize = 8 / (window.currentZoom || 1);
697
+ const anchor = document.createElementNS("http://www.w3.org/2000/svg", "rect");
698
+ anchor.setAttribute("x", x);
699
+ anchor.setAttribute("y", y);
700
+ anchor.setAttribute("width", anchorSize);
701
+ anchor.setAttribute("height", anchorSize);
702
+ anchor.setAttribute("fill", "white");
703
+ anchor.setAttribute("stroke", "black");
704
+ anchor.setAttribute("stroke-width", "1");
705
+ anchor.setAttribute("class", "resize-anchor"); // For easy removal
706
+ anchor.style.cursor = cursor;
707
+
708
+ svg.appendChild(anchor);
709
+
710
+ // Add event listeners for dragging
711
+ anchor.addEventListener('mousedown', startResize);
712
+ anchor.addEventListener('mouseup', stopResize);
713
+
714
+ }
715
+
716
+ function removeResizeAnchors() {
717
+ const anchors = svg.querySelectorAll(".resize-anchor");
718
+ anchors.forEach(anchor => svg.removeChild(anchor));
719
+ }
720
+
721
+ function startResize(event) {
722
+ event.preventDefault();
723
+ event.stopPropagation();
724
+
725
+ currentAnchor = event.target;
726
+
727
+ // Store original values at the start of resize
728
+ originalX = parseFloat(selectedImage.getAttribute('x'));
729
+ originalY = parseFloat(selectedImage.getAttribute('y'));
730
+ originalWidth = parseFloat(selectedImage.getAttribute('width'));
731
+ originalHeight = parseFloat(selectedImage.getAttribute('height'));
732
+
733
+ // Store original rotation
734
+ const transform = selectedImage.getAttribute('transform');
735
+ if (transform) {
736
+ const rotateMatch = transform.match(/rotate\(([^,]+)/);
737
+ if (rotateMatch) {
738
+ imageRotation = parseFloat(rotateMatch[1]);
739
+ }
740
+ }
741
+
742
+ svg.addEventListener('mousemove', resizeImage);
743
+ document.addEventListener('mouseup', stopResize);
744
+ }
745
+
746
+ function stopResize(event) {
747
+ stopInteracting(); // Call the combined stop function
748
+ document.removeEventListener('mouseup', stopResize); // Remove the global mouseup listener
749
+ }
750
+
751
+ function resizeImage(event) {
752
+ if (!selectedImage || !currentAnchor) return;
753
+
754
+ const { x: globalX, y: globalY } = getSVGCoordsFromMouse(event);
755
+
756
+ // Use ORIGINAL center for consistent inverse rotation (avoids drift)
757
+ const centerX = originalX + originalWidth / 2;
758
+ const centerY = originalY + originalHeight / 2;
759
+
760
+ // Convert mouse position to local coordinates accounting for rotation
761
+ let localX = globalX;
762
+ let localY = globalY;
763
+
764
+ if (imageRotation !== 0) {
765
+ const rotationRad = (imageRotation * Math.PI) / 180;
766
+ const translatedX = globalX - centerX;
767
+ const translatedY = globalY - centerY;
768
+ localX = translatedX * Math.cos(-rotationRad) - translatedY * Math.sin(-rotationRad) + centerX;
769
+ localY = translatedX * Math.sin(-rotationRad) + translatedY * Math.cos(-rotationRad) + centerY;
770
+ }
771
+
772
+ // Calculate resize deltas in local coordinate space
773
+ let dx = localX - originalX;
774
+ let dy = localY - originalY;
775
+
776
+ let newWidth = originalWidth;
777
+ let newHeight = originalHeight;
778
+ let newX = originalX;
779
+ let newY = originalY;
780
+ const aspectRatio = originalHeight / originalWidth;
781
+
782
+ // Track which corner is fixed (relative to original rect)
783
+ let fixedRelX, fixedRelY;
784
+
785
+ switch (currentAnchor.style.cursor) {
786
+ case "nw-resize":
787
+ newWidth = originalWidth - dx;
788
+ newHeight = originalHeight - dy;
789
+ if (aspect_ratio_lock) {
790
+ newHeight = newWidth * aspectRatio;
791
+ dy = originalHeight - newHeight;
792
+ }
793
+ newX = originalX + (originalWidth - newWidth);
794
+ newY = originalY + (originalHeight - newHeight);
795
+ fixedRelX = originalWidth; fixedRelY = originalHeight;
796
+ break;
797
+ case "ne-resize":
798
+ newWidth = dx;
799
+ newHeight = originalHeight - dy;
800
+ if (aspect_ratio_lock) {
801
+ newHeight = newWidth * aspectRatio;
802
+ dy = originalHeight - newHeight;
803
+ }
804
+ newX = originalX;
805
+ newY = originalY + (originalHeight - newHeight);
806
+ fixedRelX = 0; fixedRelY = originalHeight;
807
+ break;
808
+ case "sw-resize":
809
+ newWidth = originalWidth - dx;
810
+ newHeight = dy;
811
+ if (aspect_ratio_lock) {
812
+ newHeight = newWidth * aspectRatio;
813
+ dy = newHeight;
814
+ }
815
+ newX = originalX + (originalWidth - newWidth);
816
+ newY = originalY;
817
+ fixedRelX = originalWidth; fixedRelY = 0;
818
+ break;
819
+ case "se-resize":
820
+ newWidth = dx;
821
+ newHeight = dy;
822
+ if (aspect_ratio_lock) {
823
+ newHeight = newWidth * aspectRatio;
824
+ }
825
+ newX = originalX;
826
+ newY = originalY;
827
+ fixedRelX = 0; fixedRelY = 0;
828
+ break;
829
+ }
830
+
831
+ // Ensure minimum size
832
+ newWidth = Math.max(minImageSize, newWidth);
833
+ newHeight = Math.max(minImageSize, newHeight);
834
+
835
+ // Compensate for rotation center shift when rotated
836
+ if (imageRotation !== 0) {
837
+ const rad = (imageRotation * Math.PI) / 180;
838
+ const cosR = Math.cos(rad);
839
+ const sinR = Math.sin(rad);
840
+
841
+ // Compute rotated world position of the fixed corner in the original rect
842
+ const oldCX = originalX + originalWidth / 2;
843
+ const oldCY = originalY + originalHeight / 2;
844
+ const odx = (originalX + fixedRelX) - oldCX;
845
+ const ody = (originalY + fixedRelY) - oldCY;
846
+ const fixedWorldX = oldCX + odx * cosR - ody * sinR;
847
+ const fixedWorldY = oldCY + odx * sinR + ody * cosR;
848
+
849
+ // Determine where the fixed corner sits in the new rect (relative to new origin)
850
+ const fixedNewRelX = fixedRelX === 0 ? 0 : newWidth;
851
+ const fixedNewRelY = fixedRelY === 0 ? 0 : newHeight;
852
+
853
+ // Solve for new origin so the fixed corner stays in place
854
+ const ncx = newWidth / 2;
855
+ const ncy = newHeight / 2;
856
+ const ndx = fixedNewRelX - ncx;
857
+ const ndy = fixedNewRelY - ncy;
858
+ const rotX = ncx + ndx * cosR - ndy * sinR;
859
+ const rotY = ncy + ndx * sinR + ndy * cosR;
860
+
861
+ newX = fixedWorldX - rotX;
862
+ newY = fixedWorldY - rotY;
863
+ }
864
+
865
+ // Apply the new dimensions and position
866
+ selectedImage.setAttribute('width', newWidth);
867
+ selectedImage.setAttribute('height', newHeight);
868
+ selectedImage.setAttribute('x', newX);
869
+ selectedImage.setAttribute('y', newY);
870
+
871
+ // Update data attributes for arrow attachment
872
+ selectedImage.setAttribute('data-shape-x', newX);
873
+ selectedImage.setAttribute('data-shape-y', newY);
874
+ selectedImage.setAttribute('data-shape-width', newWidth);
875
+ selectedImage.setAttribute('data-shape-height', newHeight);
876
+
877
+ // Reapply the rotation transform with the new center
878
+ const newCenterX = newX + newWidth / 2;
879
+ const newCenterY = newY + newHeight / 2;
880
+ selectedImage.setAttribute('transform', `rotate(${imageRotation}, ${newCenterX}, ${newCenterY})`);
881
+
882
+ // Update attached arrows during resize
883
+ updateArrowsForShape(selectedImage);
884
+
885
+ // Update the selection outline and anchors
886
+ removeSelectionOutline();
887
+ addSelectionOutline();
888
+ }
889
+
890
+ function stopRotation(event) {
891
+ if (!isRotatingImage) return;
892
+ stopInteracting();
893
+ isRotatingImage = false;
894
+ startRotationMouseAngle = null;
895
+ startImageRotation = null;
896
+ svg.removeEventListener('mousemove', rotateImage);
897
+ document.removeEventListener('mouseup', stopRotation);
898
+ svg.style.cursor = 'default';
899
+ }
900
+
901
+ function startDrag(event) {
902
+ if (!isSelectionToolActive || !selectedImage) return;
903
+
904
+ event.preventDefault();
905
+ event.stopPropagation();
906
+
907
+ isDragging = true;
908
+
909
+ // Store original values at the start of drag
910
+ originalX = parseFloat(selectedImage.getAttribute('x'));
911
+ originalY = parseFloat(selectedImage.getAttribute('y'));
912
+ originalWidth = parseFloat(selectedImage.getAttribute('width'));
913
+ originalHeight = parseFloat(selectedImage.getAttribute('height'));
914
+
915
+ // Store original rotation
916
+ const transform = selectedImage.getAttribute('transform');
917
+ if (transform) {
918
+ const rotateMatch = transform.match(/rotate\(([^,]+)/);
919
+ if (rotateMatch) {
920
+ imageRotation = parseFloat(rotateMatch[1]);
921
+ }
922
+ }
923
+
924
+ // Find the ImageShape wrapper for frame functionality
925
+ let imageShape = null;
926
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
927
+ imageShape = shapes.find(shape => shape.shapeName === 'image' && shape.element === selectedImage);
928
+ }
929
+
930
+ if (imageShape) {
931
+ // Store initial frame state
932
+ draggedShapeInitialFrameImage = imageShape.parentFrame || null;
933
+
934
+ // Temporarily remove from frame clipping if dragging
935
+ if (imageShape.parentFrame) {
936
+ imageShape.parentFrame.temporarilyRemoveFromFrame(imageShape);
937
+ }
938
+ }
939
+
940
+ const { x, y } = getSVGCoordsFromMouse(event);
941
+ dragOffsetX = x - parseFloat(selectedImage.getAttribute('x'));
942
+ dragOffsetY = y - parseFloat(selectedImage.getAttribute('y'));
943
+
944
+ svg.addEventListener('mousemove', dragImage);
945
+ document.addEventListener('mouseup', stopDrag);
946
+ }
947
+
948
+ function dragImage(event) {
949
+ if (!isDragging || !selectedImage) return;
950
+
951
+ const { x, y } = getSVGCoordsFromMouse(event);
952
+ let newX = x - dragOffsetX;
953
+ let newY = y - dragOffsetY;
954
+
955
+ selectedImage.setAttribute('x', newX);
956
+ selectedImage.setAttribute('y', newY);
957
+
958
+ // Update data attributes for arrow attachment
959
+ selectedImage.setAttribute('data-shape-x', newX);
960
+ selectedImage.setAttribute('data-shape-y', newY);
961
+
962
+ // Reapply the rotation transform with the new position
963
+ const newCenterX = newX + parseFloat(selectedImage.getAttribute('width')) / 2;
964
+ const newCenterY = newY + parseFloat(selectedImage.getAttribute('height')) / 2;
965
+ selectedImage.setAttribute('transform', `rotate(${imageRotation}, ${newCenterX}, ${newCenterY})`);
966
+
967
+ // Update frame containment for ImageShape wrapper
968
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
969
+ const imageShape = shapes.find(shape => shape.shapeName === 'image' && shape.element === selectedImage);
970
+ if (imageShape) {
971
+ imageShape.updateFrameContainment();
972
+ }
973
+ }
974
+
975
+ // Update attached arrows during drag
976
+ updateArrowsForShape(selectedImage);
977
+
978
+ // Update the selection outline and anchors
979
+ removeSelectionOutline();
980
+ addSelectionOutline();
981
+ }
982
+
983
+ function stopDrag(event) {
984
+ stopInteracting(); // Call the combined stop function
985
+ document.removeEventListener('mouseup', stopDrag); // Remove the global mouseup listener
986
+ }
987
+
988
+ function startRotation(event) {
989
+ event.preventDefault();
990
+ event.stopPropagation();
991
+
992
+ if (!selectedImage) return;
993
+
994
+ isRotatingImage = true;
995
+
996
+ // Get image center
997
+ const imgX = parseFloat(selectedImage.getAttribute('x'));
998
+ const imgY = parseFloat(selectedImage.getAttribute('y'));
999
+ const imgWidth = parseFloat(selectedImage.getAttribute('width'));
1000
+ const imgHeight = parseFloat(selectedImage.getAttribute('height'));
1001
+
1002
+ const centerX = imgX + imgWidth / 2;
1003
+ const centerY = imgY + imgHeight / 2;
1004
+
1005
+ // Calculate initial mouse angle relative to image center
1006
+ const { x: mouseX, y: mouseY } = getSVGCoordsFromMouse(event);
1007
+
1008
+ startRotationMouseAngle = Math.atan2(mouseY - centerY, mouseX - centerX) * 180 / Math.PI;
1009
+ startImageRotation = imageRotation;
1010
+
1011
+ svg.addEventListener('mousemove', rotateImage);
1012
+ document.addEventListener('mouseup', stopRotation);
1013
+
1014
+ svg.style.cursor = 'grabbing';
1015
+ }
1016
+
1017
+ function rotateImage(event) {
1018
+ if (!isRotatingImage || !selectedImage) return;
1019
+
1020
+ // Get image center
1021
+ const imgX = parseFloat(selectedImage.getAttribute('x'));
1022
+ const imgY = parseFloat(selectedImage.getAttribute('y'));
1023
+ const imgWidth = parseFloat(selectedImage.getAttribute('width'));
1024
+ const imgHeight = parseFloat(selectedImage.getAttribute('height'));
1025
+
1026
+ const centerX = imgX + imgWidth / 2;
1027
+ const centerY = imgY + imgHeight / 2;
1028
+
1029
+ // Calculate current mouse angle
1030
+ const { x: mouseX, y: mouseY } = getSVGCoordsFromMouse(event);
1031
+
1032
+ const currentMouseAngle = Math.atan2(mouseY - centerY, mouseX - centerX) * 180 / Math.PI;
1033
+ const angleDiff = currentMouseAngle - startRotationMouseAngle;
1034
+
1035
+ imageRotation = startImageRotation + angleDiff;
1036
+ imageRotation = imageRotation % 360;
1037
+ if (imageRotation < 0) imageRotation += 360;
1038
+
1039
+ // Apply rotation transform
1040
+ selectedImage.setAttribute('transform', `rotate(${imageRotation}, ${centerX}, ${centerY})`);
1041
+
1042
+ // Update data attribute for arrow attachment
1043
+ selectedImage.setAttribute('data-shape-rotation', imageRotation);
1044
+
1045
+ // Update attached arrows during rotation
1046
+ updateArrowsForShape(selectedImage);
1047
+
1048
+ // Update the selection outline and anchors
1049
+ removeSelectionOutline();
1050
+ addSelectionOutline();
1051
+ }
1052
+
1053
+ function stopInteracting() {
1054
+ // Store transform data before stopping interaction
1055
+ if (selectedImage && (isDragging || isRotatingImage || currentAnchor)) {
1056
+ const newPos = {
1057
+ x: parseFloat(selectedImage.getAttribute('x')),
1058
+ y: parseFloat(selectedImage.getAttribute('y')),
1059
+ width: parseFloat(selectedImage.getAttribute('width')),
1060
+ height: parseFloat(selectedImage.getAttribute('height')),
1061
+ rotation: imageRotation
1062
+ };
1063
+
1064
+ // Get the original rotation from the stored start rotation or current rotation
1065
+ let originalRotation = imageRotation;
1066
+ if (isRotatingImage && startImageRotation !== null) {
1067
+ originalRotation = startImageRotation;
1068
+ }
1069
+
1070
+ const oldPos = {
1071
+ x: originalX,
1072
+ y: originalY,
1073
+ width: originalWidth,
1074
+ height: originalHeight,
1075
+ rotation: originalRotation
1076
+ };
1077
+
1078
+ // Find the ImageShape wrapper for frame tracking
1079
+ let imageShape = null;
1080
+ if (typeof shapes !== 'undefined' && Array.isArray(shapes)) {
1081
+ imageShape = shapes.find(shape => shape.shapeName === 'image' && shape.element === selectedImage);
1082
+ }
1083
+
1084
+ // Add frame information for undo tracking
1085
+ const oldPosWithFrame = {
1086
+ ...oldPos,
1087
+ parentFrame: draggedShapeInitialFrameImage
1088
+ };
1089
+ const newPosWithFrame = {
1090
+ ...newPos,
1091
+ parentFrame: imageShape ? imageShape.parentFrame : null
1092
+ };
1093
+
1094
+ // Only push transform action if something actually changed
1095
+ const stateChanged = newPos.x !== oldPos.x || newPos.y !== oldPos.y ||
1096
+ newPos.width !== oldPos.width || newPos.height !== oldPos.height ||
1097
+ newPos.rotation !== oldPos.rotation;
1098
+ const frameChanged = oldPosWithFrame.parentFrame !== newPosWithFrame.parentFrame;
1099
+
1100
+ if (stateChanged || frameChanged) {
1101
+ pushTransformAction({
1102
+ type: 'image',
1103
+ element: selectedImage,
1104
+ restore: (pos) => {
1105
+ selectedImage.setAttribute('x', pos.x);
1106
+ selectedImage.setAttribute('y', pos.y);
1107
+ selectedImage.setAttribute('width', pos.width);
1108
+ selectedImage.setAttribute('height', pos.height);
1109
+ const centerX = pos.x + pos.width / 2;
1110
+ const centerY = pos.y + pos.height / 2;
1111
+ selectedImage.setAttribute('transform', `rotate(${pos.rotation}, ${centerX}, ${centerY})`);
1112
+ imageRotation = pos.rotation;
1113
+
1114
+ // Update selection outline if image is selected
1115
+ if (selectedImage) {
1116
+ removeSelectionOutline();
1117
+ addSelectionOutline();
1118
+ }
1119
+
1120
+ // Update data attributes for arrow attachment consistency
1121
+ selectedImage.setAttribute('data-shape-x', pos.x);
1122
+ selectedImage.setAttribute('data-shape-y', pos.y);
1123
+ selectedImage.setAttribute('data-shape-width', pos.width);
1124
+ selectedImage.setAttribute('data-shape-height', pos.height);
1125
+ selectedImage.setAttribute('data-shape-rotation', pos.rotation);
1126
+
1127
+ // Update attached arrows
1128
+ updateArrowsForShape(selectedImage);
1129
+ }
1130
+ }, oldPosWithFrame, newPosWithFrame);
1131
+ }
1132
+
1133
+ // Handle frame containment changes after drag
1134
+ if (isDragging && imageShape) {
1135
+ const finalFrame = hoveredFrameImage;
1136
+
1137
+ // If shape moved to a different frame
1138
+ if (draggedShapeInitialFrameImage !== finalFrame) {
1139
+ // Remove from initial frame
1140
+ if (draggedShapeInitialFrameImage) {
1141
+ draggedShapeInitialFrameImage.removeShapeFromFrame(imageShape);
1142
+ }
1143
+
1144
+ // Add to new frame
1145
+ if (finalFrame) {
1146
+ finalFrame.addShapeToFrame(imageShape);
1147
+ }
1148
+
1149
+ // Track the frame change for undo
1150
+ if (frameChanged) {
1151
+ pushFrameAttachmentAction(finalFrame || draggedShapeInitialFrameImage, imageShape,
1152
+ finalFrame ? 'attach' : 'detach', draggedShapeInitialFrameImage);
1153
+ }
1154
+ } else if (draggedShapeInitialFrameImage) {
1155
+ // Shape stayed in same frame, restore clipping
1156
+ draggedShapeInitialFrameImage.restoreToFrame(imageShape);
1157
+ }
1158
+ }
1159
+
1160
+ draggedShapeInitialFrameImage = null;
1161
+ }
1162
+
1163
+ // Clear frame highlighting
1164
+ if (hoveredFrameImage) {
1165
+ hoveredFrameImage.removeHighlight();
1166
+ hoveredFrameImage = null;
1167
+ }
1168
+
1169
+ isDragging = false;
1170
+ isRotatingImage = false;
1171
+ svg.removeEventListener('mousemove', dragImage);
1172
+ svg.removeEventListener('mousemove', resizeImage);
1173
+ svg.removeEventListener('mousemove', rotateImage);
1174
+ currentAnchor = null;
1175
+ startRotationMouseAngle = null;
1176
+ startImageRotation = null;
1177
+
1178
+ // Update originalX, originalY, originalWidth and originalHeight after dragging/resizing is complete
1179
+ if (selectedImage) {
1180
+ originalX = parseFloat(selectedImage.getAttribute('x'));
1181
+ originalY = parseFloat(selectedImage.getAttribute('y'));
1182
+ originalWidth = parseFloat(selectedImage.getAttribute('width'));
1183
+ originalHeight = parseFloat(selectedImage.getAttribute('height'));
1184
+
1185
+ // Update the current rotation from the image transform
1186
+ const transform = selectedImage.getAttribute('transform');
1187
+ if (transform) {
1188
+ const rotateMatch = transform.match(/rotate\(([^,]+)/);
1189
+ if (rotateMatch) {
1190
+ imageRotation = parseFloat(rotateMatch[1]);
1191
+ }
1192
+ }
1193
+
1194
+ // Update data attributes for arrow attachment consistency
1195
+ selectedImage.setAttribute('data-shape-x', originalX);
1196
+ selectedImage.setAttribute('data-shape-y', originalY);
1197
+ selectedImage.setAttribute('data-shape-width', originalWidth);
1198
+ selectedImage.setAttribute('data-shape-height', originalHeight);
1199
+ selectedImage.setAttribute('data-shape-rotation', imageRotation);
1200
+
1201
+ // Update attached arrows after interaction ends
1202
+ updateArrowsForShape(selectedImage);
1203
+ }
1204
+ }
1205
+
1206
+ // Add delete functionality for images
1207
+ function deleteCurrentImage() {
1208
+ if (selectedImage) {
1209
+ // Find the ImageShape wrapper
1210
+ let imageShape = (typeof shapes !== 'undefined' && Array.isArray(shapes))
1211
+ ? shapes.find(s => s.shapeName === 'image' && s.element === selectedImage)
1212
+ : null;
1213
+
1214
+ // Abort any in-progress upload
1215
+ if (imageShape?.uploadAbortController) {
1216
+ imageShape.uploadAbortController.abort();
1217
+ imageShape.removeUploadIndicator();
1218
+ }
1219
+
1220
+ // Release image bytes from room limit
1221
+ const freedBytes = selectedImage.__fileSize || 0;
1222
+ window.__roomImageBytesUsed = Math.max(0, (window.__roomImageBytesUsed || 0) - freedBytes);
1223
+
1224
+ // If image is hosted on Cloudinary, delete it from storage
1225
+ const imgHref = selectedImage.getAttribute('href') || selectedImage.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '';
1226
+ if (imgHref.includes('cloudinary.com') || imgHref.includes('res.cloudinary')) {
1227
+ const match = imgHref.match(/\/upload\/(?:v\d+\/)?(lixsketch\/.+?)(?:\.\w+)?$/);
1228
+ if (match) {
1229
+ const publicId = match[1];
1230
+ const workerUrl = window.__WORKER_URL;
1231
+ if (workerUrl) {
1232
+ fetch(`${workerUrl}/api/images/delete`, {
1233
+ method: 'DELETE',
1234
+ headers: { 'Content-Type': 'application/json' },
1235
+ body: JSON.stringify({ publicId }),
1236
+ }).catch(err => console.warn('[ImageTool] Cloudinary cleanup failed:', err));
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ if (imageShape) {
1242
+ const idx = shapes.indexOf(imageShape);
1243
+ if (idx !== -1) shapes.splice(idx, 1);
1244
+
1245
+ // Remove the group (which contains the image)
1246
+ if (imageShape.group && imageShape.group.parentNode) {
1247
+ imageShape.group.parentNode.removeChild(imageShape.group);
1248
+ }
1249
+ }
1250
+
1251
+ // Fallback: if no ImageShape wrapper found, remove the image directly
1252
+ if (!imageShape && selectedImage.parentNode) {
1253
+ selectedImage.parentNode.removeChild(selectedImage);
1254
+ }
1255
+
1256
+ // Clean up any arrow attachments before deleting
1257
+ if (typeof cleanupAttachments === 'function') {
1258
+ cleanupAttachments(selectedImage);
1259
+ }
1260
+
1261
+ // Push delete action for undo
1262
+ if (imageShape) {
1263
+ pushDeleteAction(imageShape);
1264
+ }
1265
+
1266
+ // Clean up selection
1267
+ removeSelectionOutline();
1268
+ selectedImage = null;
1269
+ }
1270
+ }
1271
+
1272
+ document.addEventListener('keydown', (e) => {
1273
+ if (e.key === 'Delete' && selectedImage) {
1274
+ deleteCurrentImage();
1275
+ }
1276
+ });
1277
+
1278
+ // Expose upload pipeline globally so generated/pasted images can use it
1279
+ window.uploadImageToCloudinary = uploadImageToCloudinary;
1280
+
1281
+ // Window bridge: allow React UI to trigger the file picker
1282
+ window.openImageFilePicker = function() {
1283
+ isImageToolActive = true;
1284
+ const fileInput = document.createElement('input');
1285
+ fileInput.type = 'file';
1286
+ fileInput.accept = 'image/*';
1287
+ fileInput.style.display = 'none';
1288
+ document.body.appendChild(fileInput);
1289
+
1290
+ let fileSelected = false;
1291
+
1292
+ fileInput.addEventListener('change', (event) => {
1293
+ const file = event.target.files[0];
1294
+ if (file) {
1295
+ fileSelected = true;
1296
+ handleImageUpload(file);
1297
+ }
1298
+ document.body.removeChild(fileInput);
1299
+ });
1300
+
1301
+ // Detect cancel: when focus returns to window without a file being selected
1302
+ const onFocus = () => {
1303
+ window.removeEventListener('focus', onFocus);
1304
+ // Delay to let 'change' fire first if a file was selected
1305
+ setTimeout(() => {
1306
+ if (!fileSelected) {
1307
+ // User cancelled — switch back to select tool
1308
+ isImageToolActive = false;
1309
+ if (window.__sketchEngine?.setActiveTool) {
1310
+ window.__sketchEngine.setActiveTool('select');
1311
+ }
1312
+ if (fileInput.parentNode) {
1313
+ document.body.removeChild(fileInput);
1314
+ }
1315
+ }
1316
+ }, 300);
1317
+ };
1318
+ window.addEventListener('focus', onFocus);
1319
+
1320
+ fileInput.click();
1321
+ };
1322
+
1323
+ export { handleMouseDownImage, handleMouseMoveImage, handleMouseUpImage };