figrecipe 0.7.4__py3-none-any.whl → 0.9.0__py3-none-any.whl

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 (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """External image drop JavaScript for the figure editor.
4
+
5
+ This module contains the JavaScript code for:
6
+ - Handling drag & drop of external images onto the editor
7
+ - Creating imshow panels from dropped images
8
+ - Handling recipe file drops
9
+ """
10
+
11
+ SCRIPTS_IMAGE_DROP = """
12
+ // ===== EXTERNAL IMAGE/FILE DROP =====
13
+
14
+ let dropOverlay = null;
15
+
16
+ // Initialize drop zone functionality
17
+ function initImageDrop() {
18
+ console.log('[ImageDrop] initImageDrop called');
19
+ const zoomContainer = document.getElementById('zoom-container');
20
+ const previewContainer = document.getElementById('preview-wrapper');
21
+
22
+ if (!previewContainer) {
23
+ console.error('[ImageDrop] preview-wrapper not found!');
24
+ return;
25
+ }
26
+
27
+ // Create drop overlay
28
+ dropOverlay = document.createElement('div');
29
+ dropOverlay.id = 'drop-overlay';
30
+ dropOverlay.innerHTML = `
31
+ <div class="drop-message">
32
+ <div class="drop-icon">📷</div>
33
+ <div class="drop-text">Drop image to add as panel</div>
34
+ <div class="drop-subtext">Supports PNG, JPG, GIF, YAML recipe files</div>
35
+ </div>
36
+ `;
37
+ dropOverlay.style.cssText = `
38
+ position: absolute;
39
+ top: 0;
40
+ left: 0;
41
+ right: 0;
42
+ bottom: 0;
43
+ background: rgba(37, 99, 235, 0.9);
44
+ display: none;
45
+ align-items: center;
46
+ justify-content: center;
47
+ z-index: 2000;
48
+ pointer-events: none;
49
+ `;
50
+ previewContainer.style.position = 'relative';
51
+ previewContainer.appendChild(dropOverlay);
52
+
53
+ // Style the drop message
54
+ const style = document.createElement('style');
55
+ style.textContent = `
56
+ .drop-message {
57
+ text-align: center;
58
+ color: white;
59
+ }
60
+ .drop-icon {
61
+ font-size: 64px;
62
+ margin-bottom: 16px;
63
+ }
64
+ .drop-text {
65
+ font-size: 24px;
66
+ font-weight: bold;
67
+ margin-bottom: 8px;
68
+ }
69
+ .drop-subtext {
70
+ font-size: 14px;
71
+ opacity: 0.8;
72
+ }
73
+ `;
74
+ document.head.appendChild(style);
75
+
76
+ // Add drag & drop event listeners
77
+ previewContainer.addEventListener('dragenter', handleDragEnter);
78
+ previewContainer.addEventListener('dragover', handleDragOver);
79
+ previewContainer.addEventListener('dragleave', handleDragLeave);
80
+ previewContainer.addEventListener('drop', handleDrop);
81
+
82
+ console.log('[ImageDrop] Drop zone initialized');
83
+ }
84
+
85
+ // Handle drag enter
86
+ function handleDragEnter(event) {
87
+ event.preventDefault();
88
+ event.stopPropagation();
89
+
90
+ // Always show overlay if dragging files - browser restricts type info until drop
91
+ if (hasAnyFiles(event)) {
92
+ showDropOverlay();
93
+ }
94
+ }
95
+
96
+ // Handle drag over
97
+ function handleDragOver(event) {
98
+ event.preventDefault();
99
+ event.stopPropagation();
100
+
101
+ // Must call preventDefault to allow drop
102
+ if (hasAnyFiles(event)) {
103
+ event.dataTransfer.dropEffect = 'copy';
104
+ }
105
+ }
106
+
107
+ // Check if event has any files (permissive check for dragenter/dragover)
108
+ function hasAnyFiles(event) {
109
+ // Check dataTransfer.types for 'Files' - most reliable cross-browser
110
+ if (event.dataTransfer.types) {
111
+ for (const type of event.dataTransfer.types) {
112
+ if (type === 'Files' || type === 'application/x-moz-file') {
113
+ return true;
114
+ }
115
+ }
116
+ }
117
+ // Fallback: check items
118
+ const items = event.dataTransfer.items;
119
+ if (items && items.length > 0) {
120
+ for (const item of items) {
121
+ if (item.kind === 'file') {
122
+ return true;
123
+ }
124
+ }
125
+ }
126
+ return false;
127
+ }
128
+
129
+ // Handle drag leave
130
+ function handleDragLeave(event) {
131
+ event.preventDefault();
132
+ event.stopPropagation();
133
+
134
+ // Only hide if leaving the container entirely
135
+ const rect = event.currentTarget.getBoundingClientRect();
136
+ if (event.clientX < rect.left || event.clientX > rect.right ||
137
+ event.clientY < rect.top || event.clientY > rect.bottom) {
138
+ hideDropOverlay();
139
+ }
140
+ }
141
+
142
+ // Handle drop
143
+ async function handleDrop(event) {
144
+ event.preventDefault();
145
+ event.stopPropagation();
146
+ hideDropOverlay();
147
+
148
+ const files = event.dataTransfer.files;
149
+ if (files.length === 0) {
150
+ // Try to get image from URL (dragged from browser)
151
+ const imageUrl = event.dataTransfer.getData('text/uri-list') ||
152
+ event.dataTransfer.getData('text/plain');
153
+ if (imageUrl && isImageUrl(imageUrl)) {
154
+ await handleImageUrl(imageUrl, event);
155
+ return;
156
+ }
157
+ console.log('[ImageDrop] No files dropped');
158
+ return;
159
+ }
160
+
161
+ for (const file of files) {
162
+ if (isImageFile(file)) {
163
+ await handleImageFile(file, event);
164
+ } else if (isRecipeFile(file)) {
165
+ await handleRecipeFile(file);
166
+ } else {
167
+ console.log('[ImageDrop] Unsupported file type:', file.type);
168
+ }
169
+ }
170
+ }
171
+
172
+ // Check if event has valid files
173
+ function hasValidFiles(event) {
174
+ const items = event.dataTransfer.items;
175
+ if (!items) return false;
176
+
177
+ for (const item of items) {
178
+ if (item.kind === 'file') {
179
+ const type = item.type;
180
+ // Accept known image/yaml types
181
+ if (type.startsWith('image/') ||
182
+ type === 'application/x-yaml' ||
183
+ type === 'text/yaml') {
184
+ return true;
185
+ }
186
+ // When dragging from file system, type may be empty
187
+ // Accept any file and filter by extension in handleDrop
188
+ if (type === '' || type === 'application/octet-stream') {
189
+ return true;
190
+ }
191
+ }
192
+ // Also accept URLs (images dragged from browser)
193
+ if (item.kind === 'string' && item.type === 'text/uri-list') {
194
+ return true;
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+
200
+ // Check if file is an image
201
+ function isImageFile(file) {
202
+ if (file.type.startsWith('image/')) {
203
+ return true;
204
+ }
205
+ // Fallback to extension check when type is empty (Windows file drag)
206
+ const ext = file.name.toLowerCase().split('.').pop();
207
+ return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext);
208
+ }
209
+
210
+ // Check if file is a recipe file
211
+ function isRecipeFile(file) {
212
+ return file.name.endsWith('.yaml') ||
213
+ file.name.endsWith('.yml') ||
214
+ file.type === 'application/x-yaml' ||
215
+ file.type === 'text/yaml';
216
+ }
217
+
218
+ // Check if URL points to an image
219
+ function isImageUrl(url) {
220
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
221
+ const lowerUrl = url.toLowerCase();
222
+ return imageExtensions.some(ext => lowerUrl.includes(ext));
223
+ }
224
+
225
+ // Handle dropped image file
226
+ async function handleImageFile(file, event) {
227
+ console.log('[ImageDrop] Processing image file:', file.name);
228
+ document.body.classList.add('loading');
229
+
230
+ try {
231
+ // Get drop position relative to image
232
+ const img = document.getElementById('preview-image');
233
+ let dropX = 0.5, dropY = 0.5; // Default to center
234
+
235
+ if (img && figSize.width_mm && figSize.height_mm) {
236
+ const rect = img.getBoundingClientRect();
237
+ const x = event.clientX - rect.left;
238
+ const y = event.clientY - rect.top;
239
+ dropX = Math.max(0, Math.min(1, x / rect.width));
240
+ dropY = Math.max(0, Math.min(1, y / rect.height));
241
+ }
242
+
243
+ // Read file as base64
244
+ const base64 = await fileToBase64(file);
245
+
246
+ // Send to server
247
+ const response = await fetch('/add_image_panel', {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/json' },
250
+ body: JSON.stringify({
251
+ image_data: base64,
252
+ filename: file.name,
253
+ drop_x: dropX,
254
+ drop_y: dropY
255
+ })
256
+ });
257
+
258
+ const data = await response.json();
259
+
260
+ if (data.success) {
261
+ // Update preview
262
+ const previewImg = document.getElementById('preview-image');
263
+ if (previewImg) {
264
+ await new Promise((resolve) => {
265
+ previewImg.onload = resolve;
266
+ previewImg.src = 'data:image/png;base64,' + data.image;
267
+ });
268
+ }
269
+
270
+ // Update state
271
+ if (data.img_size) {
272
+ currentImgWidth = data.img_size.width;
273
+ currentImgHeight = data.img_size.height;
274
+ }
275
+ if (data.bboxes) {
276
+ currentBboxes = data.bboxes;
277
+ loadHitmap();
278
+ updateHitRegions();
279
+ }
280
+ await loadPanelPositions();
281
+
282
+ console.log('[ImageDrop] Image panel added successfully');
283
+ } else {
284
+ console.error('[ImageDrop] Failed to add image:', data.error);
285
+ alert('Failed to add image: ' + data.error);
286
+ }
287
+ } catch (error) {
288
+ console.error('[ImageDrop] Error processing image:', error);
289
+ alert('Error processing image: ' + error.message);
290
+ }
291
+
292
+ document.body.classList.remove('loading');
293
+ }
294
+
295
+ // Handle image URL (dragged from browser)
296
+ async function handleImageUrl(url, event) {
297
+ console.log('[ImageDrop] Processing image URL:', url);
298
+ document.body.classList.add('loading');
299
+
300
+ try {
301
+ // Get drop position
302
+ const img = document.getElementById('preview-image');
303
+ let dropX = 0.5, dropY = 0.5;
304
+
305
+ if (img && figSize.width_mm && figSize.height_mm) {
306
+ const rect = img.getBoundingClientRect();
307
+ const x = event.clientX - rect.left;
308
+ const y = event.clientY - rect.top;
309
+ dropX = Math.max(0, Math.min(1, x / rect.width));
310
+ dropY = Math.max(0, Math.min(1, y / rect.height));
311
+ }
312
+
313
+ // Send URL to server
314
+ const response = await fetch('/add_image_from_url', {
315
+ method: 'POST',
316
+ headers: { 'Content-Type': 'application/json' },
317
+ body: JSON.stringify({
318
+ url: url,
319
+ drop_x: dropX,
320
+ drop_y: dropY
321
+ })
322
+ });
323
+
324
+ const data = await response.json();
325
+
326
+ if (data.success) {
327
+ // Update preview
328
+ const previewImg = document.getElementById('preview-image');
329
+ if (previewImg) {
330
+ await new Promise((resolve) => {
331
+ previewImg.onload = resolve;
332
+ previewImg.src = 'data:image/png;base64,' + data.image;
333
+ });
334
+ }
335
+
336
+ if (data.img_size) {
337
+ currentImgWidth = data.img_size.width;
338
+ currentImgHeight = data.img_size.height;
339
+ }
340
+ if (data.bboxes) {
341
+ currentBboxes = data.bboxes;
342
+ loadHitmap();
343
+ updateHitRegions();
344
+ }
345
+ await loadPanelPositions();
346
+
347
+ console.log('[ImageDrop] Image from URL added successfully');
348
+ } else {
349
+ console.error('[ImageDrop] Failed to add image from URL:', data.error);
350
+ alert('Failed to add image: ' + data.error);
351
+ }
352
+ } catch (error) {
353
+ console.error('[ImageDrop] Error processing URL:', error);
354
+ alert('Error processing image URL: ' + error.message);
355
+ }
356
+
357
+ document.body.classList.remove('loading');
358
+ }
359
+
360
+ // Handle dropped recipe file
361
+ async function handleRecipeFile(file) {
362
+ console.log('[ImageDrop] Processing recipe file:', file.name);
363
+ document.body.classList.add('loading');
364
+
365
+ try {
366
+ const content = await file.text();
367
+
368
+ const response = await fetch('/load_recipe', {
369
+ method: 'POST',
370
+ headers: { 'Content-Type': 'application/json' },
371
+ body: JSON.stringify({
372
+ recipe_content: content,
373
+ filename: file.name
374
+ })
375
+ });
376
+
377
+ const data = await response.json();
378
+
379
+ if (data.success) {
380
+ // Reload the editor with new figure
381
+ window.location.reload();
382
+ } else {
383
+ console.error('[ImageDrop] Failed to load recipe:', data.error);
384
+ alert('Failed to load recipe: ' + data.error);
385
+ }
386
+ } catch (error) {
387
+ console.error('[ImageDrop] Error processing recipe:', error);
388
+ alert('Error processing recipe: ' + error.message);
389
+ }
390
+
391
+ document.body.classList.remove('loading');
392
+ }
393
+
394
+ // Convert file to base64
395
+ function fileToBase64(file) {
396
+ return new Promise((resolve, reject) => {
397
+ const reader = new FileReader();
398
+ reader.onload = () => {
399
+ // Remove data URL prefix (e.g., "data:image/png;base64,")
400
+ const base64 = reader.result.split(',')[1];
401
+ resolve(base64);
402
+ };
403
+ reader.onerror = reject;
404
+ reader.readAsDataURL(file);
405
+ });
406
+ }
407
+
408
+ // Show drop overlay
409
+ function showDropOverlay() {
410
+ if (dropOverlay) {
411
+ dropOverlay.style.display = 'flex';
412
+ }
413
+ }
414
+
415
+ // Hide drop overlay
416
+ function hideDropOverlay() {
417
+ if (dropOverlay) {
418
+ dropOverlay.style.display = 'none';
419
+ }
420
+ }
421
+
422
+ // Initialize on DOMContentLoaded
423
+ document.addEventListener('DOMContentLoaded', initImageDrop);
424
+ """
425
+
426
+ __all__ = ["SCRIPTS_IMAGE_DROP"]
427
+
428
+ # EOF
@@ -56,6 +56,11 @@ function startLegendDrag(event, legendKey) {
56
56
  event.preventDefault();
57
57
  event.stopPropagation();
58
58
 
59
+ // Capture state before drag for undo
60
+ if (typeof pushToHistory === 'function') {
61
+ pushToHistory();
62
+ }
63
+
59
64
  isDraggingLegend = true;
60
65
  legendDragStartPos = { x: event.clientX, y: event.clientY };
61
66
  legendDragStartBbox = { ...bbox };
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Multi-selection JavaScript for the figure editor.
4
+
5
+ This module contains the JavaScript code for:
6
+ - Ctrl+Click to add/remove elements from selection
7
+ - Managing multiple selected elements
8
+ - Drawing multi-selection highlights
9
+ """
10
+
11
+ SCRIPTS_MULTI_SELECT = """
12
+ // ===== MULTI-SELECTION (Ctrl+Click) =====
13
+
14
+ // Array of selected elements (each element is {key, type, x, y, width, height, ...})
15
+ let selectedElements = [];
16
+
17
+ // Check if multi-select mode is active (Ctrl or Cmd key held)
18
+ function isMultiSelectMode(event) {
19
+ return event && (event.ctrlKey || event.metaKey);
20
+ }
21
+
22
+ // Check if an element is currently selected
23
+ function isElementSelected(key) {
24
+ return selectedElements.some(el => el.key === key);
25
+ }
26
+
27
+ // Add element to selection (if not already selected)
28
+ function addToSelection(element) {
29
+ if (!element || !element.key) return;
30
+ if (isElementSelected(element.key)) return;
31
+
32
+ selectedElements.push(element);
33
+ console.log('[MultiSelect] Added to selection:', element.key, '- total:', selectedElements.length);
34
+ }
35
+
36
+ // Remove element from selection
37
+ function removeFromSelection(key) {
38
+ const idx = selectedElements.findIndex(el => el.key === key);
39
+ if (idx >= 0) {
40
+ selectedElements.splice(idx, 1);
41
+ console.log('[MultiSelect] Removed from selection:', key, '- total:', selectedElements.length);
42
+ }
43
+ }
44
+
45
+ // Toggle element in selection
46
+ function toggleInSelection(element) {
47
+ if (!element || !element.key) return;
48
+
49
+ if (isElementSelected(element.key)) {
50
+ removeFromSelection(element.key);
51
+ } else {
52
+ addToSelection(element);
53
+ }
54
+ }
55
+
56
+ // Clear all selections
57
+ function clearMultiSelection() {
58
+ selectedElements = [];
59
+ console.log('[MultiSelect] Selection cleared');
60
+
61
+ // Clear visual selection
62
+ const selOverlay = document.getElementById('selection-overlay');
63
+ if (selOverlay) {
64
+ const svg = selOverlay.querySelector('svg');
65
+ if (svg) {
66
+ // Remove multi-selection highlights
67
+ svg.querySelectorAll('.multi-select-highlight').forEach(el => el.remove());
68
+ }
69
+ }
70
+ }
71
+
72
+ // Draw multi-selection highlights on the selection overlay
73
+ function drawMultiSelection() {
74
+ const selOverlay = document.getElementById('selection-overlay');
75
+ if (!selOverlay) return;
76
+
77
+ let svg = selOverlay.querySelector('svg');
78
+ if (!svg) {
79
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
80
+ svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;';
81
+ selOverlay.appendChild(svg);
82
+ }
83
+
84
+ // Remove existing multi-selection highlights
85
+ svg.querySelectorAll('.multi-select-highlight').forEach(el => el.remove());
86
+
87
+ const img = document.getElementById('preview-image');
88
+ if (!img || !figSize.width_mm || !figSize.height_mm) return;
89
+
90
+ const imgRect = img.getBoundingClientRect();
91
+ const containerRect = selOverlay.getBoundingClientRect();
92
+
93
+ // Update SVG viewBox to match image
94
+ svg.setAttribute('viewBox', `0 0 ${imgRect.width} ${imgRect.height}`);
95
+ svg.style.width = imgRect.width + 'px';
96
+ svg.style.height = imgRect.height + 'px';
97
+ svg.style.left = (imgRect.left - containerRect.left) + 'px';
98
+ svg.style.top = (imgRect.top - containerRect.top) + 'px';
99
+
100
+ // Draw highlight for each selected element
101
+ selectedElements.forEach(element => {
102
+ if (!element.x || !element.width) return;
103
+
104
+ // Convert from image pixels to display pixels
105
+ const scaleX = imgRect.width / img.naturalWidth;
106
+ const scaleY = imgRect.height / img.naturalHeight;
107
+
108
+ const x = element.x * scaleX;
109
+ const y = element.y * scaleY;
110
+ const w = element.width * scaleX;
111
+ const h = element.height * scaleY;
112
+
113
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
114
+ rect.setAttribute('class', 'multi-select-highlight');
115
+ rect.setAttribute('x', x);
116
+ rect.setAttribute('y', y);
117
+ rect.setAttribute('width', w);
118
+ rect.setAttribute('height', h);
119
+ rect.setAttribute('fill', 'rgba(37, 99, 235, 0.15)');
120
+ rect.setAttribute('stroke', '#2563eb');
121
+ rect.setAttribute('stroke-width', '2');
122
+ rect.setAttribute('stroke-dasharray', '4,2');
123
+ svg.appendChild(rect);
124
+ });
125
+
126
+ console.log('[MultiSelect] Drew selection for', selectedElements.length, 'elements');
127
+ }
128
+
129
+ // Select all elements of a specific type
130
+ function selectAllOfType(type) {
131
+ clearMultiSelection();
132
+
133
+ for (const [key, bbox] of Object.entries(currentBboxes)) {
134
+ if (key === '_meta') continue;
135
+ if (!bbox || typeof bbox.x === 'undefined') continue;
136
+
137
+ const info = (colorMap && colorMap[key]) || {};
138
+ if (info.type === type || bbox.type === type) {
139
+ addToSelection({ key, ...bbox, ...info });
140
+ }
141
+ }
142
+
143
+ drawMultiSelection();
144
+ console.log('[MultiSelect] Selected all', type, 'elements:', selectedElements.length);
145
+ }
146
+
147
+ // Get indices of selected panels (axes)
148
+ function getSelectedPanelIndices() {
149
+ return selectedElements
150
+ .filter(el => el.key && el.key.includes('_axes'))
151
+ .map(el => {
152
+ const match = el.key.match(/ax(\\d+)_axes/);
153
+ return match ? parseInt(match[1], 10) : -1;
154
+ })
155
+ .filter(idx => idx >= 0);
156
+ }
157
+
158
+ // Update UI to show multi-selection state
159
+ function updateMultiSelectionUI() {
160
+ const countEl = document.getElementById('multi-select-count');
161
+ if (countEl) {
162
+ countEl.textContent = selectedElements.length > 1
163
+ ? `${selectedElements.length} selected`
164
+ : '';
165
+ }
166
+ }
167
+
168
+ // Handle keyboard shortcuts for multi-selection
169
+ function handleMultiSelectKeyboard(event) {
170
+ // Ctrl+A: Select all panels
171
+ if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
172
+ // Only if not in an input field
173
+ if (event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
174
+ event.preventDefault();
175
+ selectAllOfType('axes');
176
+ }
177
+ }
178
+
179
+ // Escape: Clear selection
180
+ if (event.key === 'Escape') {
181
+ clearMultiSelection();
182
+ drawMultiSelection();
183
+ }
184
+ }
185
+
186
+ // Initialize multi-selection support
187
+ function initMultiSelect() {
188
+ console.log('[MultiSelect] Initializing multi-selection support');
189
+ document.addEventListener('keydown', handleMultiSelectKeyboard);
190
+ }
191
+
192
+ // Initialize on DOMContentLoaded
193
+ document.addEventListener('DOMContentLoaded', initMultiSelect);
194
+ """
195
+
196
+ __all__ = ["SCRIPTS_MULTI_SELECT"]
197
+
198
+ # EOF