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,307 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Panel snapping JavaScript for the figure editor.
4
+
5
+ This module contains the JavaScript code for:
6
+ - Grid snapping (snap to mm grid)
7
+ - Edge alignment (snap to other panel edges)
8
+ - Center alignment (snap to other panel centers)
9
+ - Visual alignment guides
10
+ """
11
+
12
+ SCRIPTS_PANEL_SNAP = """
13
+ // ===== PANEL SNAPPING =====
14
+
15
+ // Snapping configuration
16
+ const SNAP_CONFIG = {
17
+ enabled: true,
18
+ gridSize: 5, // mm - snap to 5mm grid
19
+ snapThreshold: 3, // mm - distance to trigger hard snap
20
+ magneticZone: 8, // mm - distance where magnetic attraction starts
21
+ magneticStrength: 0.7, // 0-1, how strongly to pull toward target
22
+ showGuides: true // Show visual alignment guides
23
+ };
24
+
25
+ // Alignment guide elements
26
+ let snapGuides = [];
27
+
28
+ // Initialize snapping UI
29
+ function initPanelSnap() {
30
+ console.log('[PanelSnap] initPanelSnap called');
31
+ const zoomContainer = document.getElementById('zoom-container');
32
+ if (!zoomContainer) return;
33
+
34
+ // Create guide line elements (2 horizontal, 2 vertical for edges/centers)
35
+ for (let i = 0; i < 4; i++) {
36
+ const guide = document.createElement('div');
37
+ guide.className = 'snap-guide';
38
+ guide.style.cssText = `
39
+ position: absolute;
40
+ background: #f59e0b;
41
+ pointer-events: none;
42
+ display: none;
43
+ z-index: 999;
44
+ `;
45
+ zoomContainer.appendChild(guide);
46
+ snapGuides.push(guide);
47
+ }
48
+ console.log('[PanelSnap] Created', snapGuides.length, 'guide elements');
49
+ }
50
+
51
+ // Find snap targets from other panels (excluding the panel being dragged)
52
+ function getSnapTargets(excludeIndex) {
53
+ const targets = { h: [], v: [] }; // horizontal and vertical snap lines
54
+ const axKeys = Object.keys(panelPositions).sort();
55
+
56
+ for (let i = 0; i < axKeys.length; i++) {
57
+ if (i === excludeIndex) continue;
58
+
59
+ const pos = panelPositions[axKeys[i]];
60
+ const left = pos.left;
61
+ const right = pos.left + pos.width;
62
+ const top = pos.top;
63
+ const bottom = pos.top + pos.height;
64
+ const centerX = pos.left + pos.width / 2;
65
+ const centerY = pos.top + pos.height / 2;
66
+
67
+ // Vertical lines (for left/right/centerX alignment)
68
+ targets.v.push({ pos: left, type: 'edge-left', panel: i });
69
+ targets.v.push({ pos: right, type: 'edge-right', panel: i });
70
+ targets.v.push({ pos: centerX, type: 'center', panel: i });
71
+
72
+ // Horizontal lines (for top/bottom/centerY alignment)
73
+ targets.h.push({ pos: top, type: 'edge-top', panel: i });
74
+ targets.h.push({ pos: bottom, type: 'edge-bottom', panel: i });
75
+ targets.h.push({ pos: centerY, type: 'center', panel: i });
76
+ }
77
+
78
+ // Add figure edges and center
79
+ targets.v.push({ pos: 0, type: 'figure-edge', panel: -1 });
80
+ targets.v.push({ pos: figSize.width_mm, type: 'figure-edge', panel: -1 });
81
+ targets.v.push({ pos: figSize.width_mm / 2, type: 'figure-center', panel: -1 });
82
+ targets.h.push({ pos: 0, type: 'figure-edge', panel: -1 });
83
+ targets.h.push({ pos: figSize.height_mm, type: 'figure-edge', panel: -1 });
84
+ targets.h.push({ pos: figSize.height_mm / 2, type: 'figure-center', panel: -1 });
85
+
86
+ return targets;
87
+ }
88
+
89
+ // Apply magnetic attraction - eases movement toward target
90
+ function applyMagnetic(value, targetPos, dist) {
91
+ const zone = SNAP_CONFIG.magneticZone;
92
+ const strength = SNAP_CONFIG.magneticStrength;
93
+ const threshold = SNAP_CONFIG.snapThreshold;
94
+
95
+ if (dist <= threshold) {
96
+ // Hard snap - lock to target
97
+ return targetPos;
98
+ } else if (dist <= zone) {
99
+ // Magnetic zone - gradual attraction
100
+ // Strength increases as we get closer (quadratic easing)
101
+ const progress = 1 - (dist - threshold) / (zone - threshold);
102
+ const eased = progress * progress * strength;
103
+ return value + (targetPos - value) * eased;
104
+ }
105
+ return value;
106
+ }
107
+
108
+ // Apply snapping to a position
109
+ // Returns { pos: {left, top}, snapped: {h: bool, v: bool}, guides: [...], magnetic: {h: bool, v: bool} }
110
+ function applySnapping(left, top, width, height, excludeIndex) {
111
+ if (!SNAP_CONFIG.enabled) {
112
+ return { pos: { left, top }, snapped: { h: false, v: false }, guides: [], magnetic: { h: false, v: false } };
113
+ }
114
+
115
+ const result = {
116
+ pos: { left, top },
117
+ snapped: { h: false, v: false },
118
+ magnetic: { h: false, v: false },
119
+ guides: []
120
+ };
121
+
122
+ // Panel edges and center
123
+ const panelLeft = left;
124
+ const panelRight = left + width;
125
+ const panelCenterX = left + width / 2;
126
+ const panelTop = top;
127
+ const panelBottom = top + height;
128
+ const panelCenterY = top + height / 2;
129
+
130
+ const targets = getSnapTargets(excludeIndex);
131
+ const threshold = SNAP_CONFIG.snapThreshold;
132
+ const zone = SNAP_CONFIG.magneticZone;
133
+
134
+ // Find best vertical snap (for X position)
135
+ let bestVSnap = null;
136
+ let bestVDist = zone + 1;
137
+
138
+ for (const target of targets.v) {
139
+ // Check left edge
140
+ const distLeft = Math.abs(panelLeft - target.pos);
141
+ if (distLeft < bestVDist) {
142
+ bestVDist = distLeft;
143
+ bestVSnap = { offset: target.pos - panelLeft, target, edge: 'left' };
144
+ }
145
+ // Check right edge
146
+ const distRight = Math.abs(panelRight - target.pos);
147
+ if (distRight < bestVDist) {
148
+ bestVDist = distRight;
149
+ bestVSnap = { offset: target.pos - panelRight, target, edge: 'right' };
150
+ }
151
+ // Check center
152
+ const distCenter = Math.abs(panelCenterX - target.pos);
153
+ if (distCenter < bestVDist) {
154
+ bestVDist = distCenter;
155
+ bestVSnap = { offset: target.pos - panelCenterX, target, edge: 'center' };
156
+ }
157
+ }
158
+
159
+ if (bestVSnap && bestVDist <= zone) {
160
+ const targetLeft = left + bestVSnap.offset;
161
+ result.pos.left = applyMagnetic(left, targetLeft, bestVDist);
162
+ result.snapped.v = bestVDist <= threshold;
163
+ result.magnetic.v = bestVDist > threshold && bestVDist <= zone;
164
+ result.guides.push({
165
+ type: 'vertical',
166
+ pos: bestVSnap.target.pos,
167
+ targetType: bestVSnap.target.type,
168
+ strength: result.snapped.v ? 1 : 1 - (bestVDist - threshold) / (zone - threshold)
169
+ });
170
+ }
171
+
172
+ // Find best horizontal snap (for Y position)
173
+ let bestHSnap = null;
174
+ let bestHDist = zone + 1;
175
+
176
+ for (const target of targets.h) {
177
+ // Check top edge
178
+ const distTop = Math.abs(panelTop - target.pos);
179
+ if (distTop < bestHDist) {
180
+ bestHDist = distTop;
181
+ bestHSnap = { offset: target.pos - panelTop, target, edge: 'top' };
182
+ }
183
+ // Check bottom edge
184
+ const distBottom = Math.abs(panelBottom - target.pos);
185
+ if (distBottom < bestHDist) {
186
+ bestHDist = distBottom;
187
+ bestHSnap = { offset: target.pos - panelBottom, target, edge: 'bottom' };
188
+ }
189
+ // Check center
190
+ const distCenter = Math.abs(panelCenterY - target.pos);
191
+ if (distCenter < bestHDist) {
192
+ bestHDist = distCenter;
193
+ bestHSnap = { offset: target.pos - panelCenterY, target, edge: 'center' };
194
+ }
195
+ }
196
+
197
+ if (bestHSnap && bestHDist <= zone) {
198
+ const targetTop = top + bestHSnap.offset;
199
+ result.pos.top = applyMagnetic(top, targetTop, bestHDist);
200
+ result.snapped.h = bestHDist <= threshold;
201
+ result.magnetic.h = bestHDist > threshold && bestHDist <= zone;
202
+ result.guides.push({
203
+ type: 'horizontal',
204
+ pos: bestHSnap.target.pos,
205
+ targetType: bestHSnap.target.type,
206
+ strength: result.snapped.h ? 1 : 1 - (bestHDist - threshold) / (zone - threshold)
207
+ });
208
+ }
209
+
210
+ // Apply grid snapping if no edge/center snap or magnetic attraction
211
+ if (!result.snapped.v && !result.magnetic.v) {
212
+ result.pos.left = snapToGrid(result.pos.left);
213
+ }
214
+ if (!result.snapped.h && !result.magnetic.h) {
215
+ result.pos.top = snapToGrid(result.pos.top);
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ // Snap value to grid
222
+ function snapToGrid(value) {
223
+ const grid = SNAP_CONFIG.gridSize;
224
+ return Math.round(value / grid) * grid;
225
+ }
226
+
227
+ // Show alignment guides with opacity based on magnetic strength
228
+ function showSnapGuides(guides, imgRect) {
229
+ if (!SNAP_CONFIG.showGuides) {
230
+ hideSnapGuides();
231
+ return;
232
+ }
233
+
234
+ const img = document.getElementById('preview-image');
235
+ if (!img) return;
236
+
237
+ // IMPORTANT: Use offsetWidth/offsetHeight (CSS dimensions) NOT imgRect (transformed by zoom)
238
+ // The guides are positioned in the zoom-container's local coordinate space,
239
+ // which is NOT affected by the CSS transform scale.
240
+ // imgRect.width/height = naturalWidth * zoomLevel (visual size, WRONG for positioning)
241
+ // img.offsetWidth/Height = naturalWidth (CSS size, CORRECT for positioning)
242
+ const scaleX = img.offsetWidth / figSize.width_mm;
243
+ const scaleY = img.offsetHeight / figSize.height_mm;
244
+
245
+ // Get image offset relative to zoom-container (its offset parent)
246
+ const imgOffsetX = img.offsetLeft;
247
+ const imgOffsetY = img.offsetTop;
248
+
249
+ // Debug logging for guide positioning
250
+ console.log('[SnapGuide] imgOffset:', imgOffsetX, imgOffsetY, '| offsetSize:', img.offsetWidth, img.offsetHeight, '| scale:', scaleX.toFixed(3), scaleY.toFixed(3));
251
+
252
+ // Hide all guides first
253
+ snapGuides.forEach(g => g.style.display = 'none');
254
+
255
+ // Show active guides
256
+ let guideIndex = 0;
257
+ for (const guide of guides) {
258
+ if (guideIndex >= snapGuides.length) break;
259
+
260
+ const el = snapGuides[guideIndex];
261
+ const isCenter = guide.targetType.includes('center');
262
+ const baseColor = isCenter ? '139, 92, 246' : '245, 158, 11'; // RGB values
263
+ const strength = guide.strength || 1;
264
+ const opacity = 0.3 + strength * 0.7; // 0.3-1.0 opacity range
265
+
266
+ if (guide.type === 'vertical') {
267
+ el.style.left = `${imgOffsetX + guide.pos * scaleX}px`;
268
+ el.style.top = `${imgOffsetY}px`;
269
+ el.style.width = strength >= 1 ? '3px' : '2px'; // Thicker when snapped
270
+ el.style.height = `${img.offsetHeight}px`; // Use CSS size, not transformed
271
+ } else {
272
+ el.style.left = `${imgOffsetX}px`;
273
+ el.style.top = `${imgOffsetY + guide.pos * scaleY}px`;
274
+ el.style.width = `${img.offsetWidth}px`; // Use CSS size, not transformed
275
+ el.style.height = strength >= 1 ? '3px' : '2px'; // Thicker when snapped
276
+ }
277
+ el.style.background = `rgba(${baseColor}, ${opacity})`;
278
+ el.style.display = 'block';
279
+ guideIndex++;
280
+ }
281
+ }
282
+
283
+ // Hide all alignment guides
284
+ function hideSnapGuides() {
285
+ snapGuides.forEach(g => g.style.display = 'none');
286
+ }
287
+
288
+ // Toggle snapping on/off
289
+ function toggleSnapping(enabled) {
290
+ SNAP_CONFIG.enabled = enabled;
291
+ console.log('[PanelSnap] Snapping', enabled ? 'enabled' : 'disabled');
292
+ if (!enabled) hideSnapGuides();
293
+ }
294
+
295
+ // Set grid size
296
+ function setSnapGridSize(size) {
297
+ SNAP_CONFIG.gridSize = size;
298
+ console.log('[PanelSnap] Grid size set to', size, 'mm');
299
+ }
300
+
301
+ // Initialize on DOMContentLoaded
302
+ document.addEventListener('DOMContentLoaded', initPanelSnap);
303
+ """
304
+
305
+ __all__ = ["SCRIPTS_PANEL_SNAP"]
306
+
307
+ # EOF
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Region selection (marquee) JavaScript for the figure editor.
4
+
5
+ This module contains the JavaScript code for:
6
+ - Drawing selection rectangle by dragging on the canvas
7
+ - Selecting all elements within the rectangle
8
+ - Combining with multi-select (Ctrl+drag to add to selection)
9
+ """
10
+
11
+ SCRIPTS_REGION_SELECT = """
12
+ // ===== REGION SELECTION (Marquee/Rectangle Selection) =====
13
+
14
+ // Region selection state
15
+ let isRegionSelecting = false;
16
+ let regionSelectStart = null; // { x, y } in mm
17
+ let regionSelectRect = null; // { x, y, width, height } in mm
18
+
19
+ // Region selection overlay element
20
+ let regionSelectOverlay = null;
21
+
22
+ // Initialize region selection
23
+ function initRegionSelect() {
24
+ console.log('[RegionSelect] Initializing region selection');
25
+
26
+ const zoomContainer = document.getElementById('zoom-container');
27
+ if (!zoomContainer) return;
28
+
29
+ // Create selection rectangle overlay
30
+ regionSelectOverlay = document.createElement('div');
31
+ regionSelectOverlay.id = 'region-select-overlay';
32
+ regionSelectOverlay.style.cssText = `
33
+ position: absolute;
34
+ border: 2px dashed #2563eb;
35
+ background: rgba(37, 99, 235, 0.1);
36
+ pointer-events: none;
37
+ display: none;
38
+ z-index: 100;
39
+ `;
40
+ zoomContainer.appendChild(regionSelectOverlay);
41
+
42
+ // Add event listeners to zoom container
43
+ zoomContainer.addEventListener('mousedown', handleRegionSelectStart);
44
+ document.addEventListener('mousemove', handleRegionSelectMove);
45
+ document.addEventListener('mouseup', handleRegionSelectEnd);
46
+ }
47
+
48
+ // Handle mousedown to start region selection
49
+ function handleRegionSelectStart(event) {
50
+ // Only start region select on left-click
51
+ if (event.button !== 0) return;
52
+
53
+ // Skip if clicking on a hit region, label, or other interactive element
54
+ const target = event.target;
55
+ if (target.closest('.hitregion-group') ||
56
+ target.closest('.panel-label-group') ||
57
+ target.closest('.hitregion-polyline') ||
58
+ target.closest('.hitregion-rect') ||
59
+ target.closest('.hitregion-circle')) {
60
+ return;
61
+ }
62
+
63
+ // Skip if modifier keys suggest other operations (Alt for cycling)
64
+ if (event.altKey) return;
65
+
66
+ // Skip if clicking directly on the preview image (handled by hitmap)
67
+ if (target.id === 'preview-image') return;
68
+
69
+ // Check if click is on empty area of zoom container or overlays
70
+ const zoomContainer = document.getElementById('zoom-container');
71
+ const hitOverlay = document.getElementById('hitregion-overlay');
72
+ const selOverlay = document.getElementById('selection-overlay');
73
+
74
+ if (target !== zoomContainer && target !== hitOverlay && target !== selOverlay) {
75
+ // Not on container/overlay background - might be clicking on shape
76
+ return;
77
+ }
78
+
79
+ const img = document.getElementById('preview-image');
80
+ if (!img || !figSize.width_mm || !figSize.height_mm) return;
81
+
82
+ const imgRect = img.getBoundingClientRect();
83
+
84
+ // Check if click is within image bounds
85
+ const x = event.clientX - imgRect.left;
86
+ const y = event.clientY - imgRect.top;
87
+ if (x < 0 || x > imgRect.width || y < 0 || y > imgRect.height) return;
88
+
89
+ // Start region selection
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+
93
+ isRegionSelecting = true;
94
+
95
+ // Convert to mm coordinates
96
+ const mmX = (x / imgRect.width) * figSize.width_mm;
97
+ const mmY = (y / imgRect.height) * figSize.height_mm;
98
+
99
+ regionSelectStart = { x: mmX, y: mmY };
100
+ regionSelectRect = { x: mmX, y: mmY, width: 0, height: 0 };
101
+
102
+ // Clear selection unless Ctrl is held (add mode)
103
+ if (!isMultiSelectMode(event)) {
104
+ clearMultiSelection();
105
+ }
106
+
107
+ // Show overlay
108
+ updateRegionSelectOverlay(imgRect);
109
+ regionSelectOverlay.style.display = 'block';
110
+
111
+ console.log('[RegionSelect] Started at', mmX.toFixed(1), mmY.toFixed(1));
112
+ }
113
+
114
+ // Handle mousemove during region selection
115
+ function handleRegionSelectMove(event) {
116
+ if (!isRegionSelecting) return;
117
+
118
+ event.preventDefault();
119
+
120
+ const img = document.getElementById('preview-image');
121
+ if (!img) return;
122
+
123
+ const imgRect = img.getBoundingClientRect();
124
+
125
+ // Convert current position to mm
126
+ const x = event.clientX - imgRect.left;
127
+ const y = event.clientY - imgRect.top;
128
+ const mmX = Math.max(0, Math.min(figSize.width_mm, (x / imgRect.width) * figSize.width_mm));
129
+ const mmY = Math.max(0, Math.min(figSize.height_mm, (y / imgRect.height) * figSize.height_mm));
130
+
131
+ // Update rect (handle negative width/height by using min/max)
132
+ regionSelectRect = {
133
+ x: Math.min(regionSelectStart.x, mmX),
134
+ y: Math.min(regionSelectStart.y, mmY),
135
+ width: Math.abs(mmX - regionSelectStart.x),
136
+ height: Math.abs(mmY - regionSelectStart.y)
137
+ };
138
+
139
+ // Update visual overlay
140
+ updateRegionSelectOverlay(imgRect);
141
+ }
142
+
143
+ // Handle mouseup to end region selection
144
+ function handleRegionSelectEnd(event) {
145
+ if (!isRegionSelecting) return;
146
+
147
+ isRegionSelecting = false;
148
+ regionSelectOverlay.style.display = 'none';
149
+
150
+ // Only select if rectangle has meaningful size (> 2mm)
151
+ if (regionSelectRect.width < 2 || regionSelectRect.height < 2) {
152
+ console.log('[RegionSelect] Rectangle too small, ignored');
153
+ regionSelectStart = null;
154
+ regionSelectRect = null;
155
+ return;
156
+ }
157
+
158
+ // Select elements within the rectangle
159
+ selectElementsInRegion(regionSelectRect, isMultiSelectMode(event));
160
+
161
+ regionSelectStart = null;
162
+ regionSelectRect = null;
163
+
164
+ console.log('[RegionSelect] Ended, selected', selectedElements.length, 'elements');
165
+ }
166
+
167
+ // Update the visual selection rectangle overlay
168
+ function updateRegionSelectOverlay(imgRect) {
169
+ if (!regionSelectOverlay || !regionSelectRect) return;
170
+
171
+ // Convert mm to pixels
172
+ const scaleX = imgRect.width / figSize.width_mm;
173
+ const scaleY = imgRect.height / figSize.height_mm;
174
+
175
+ const left = regionSelectRect.x * scaleX;
176
+ const top = regionSelectRect.y * scaleY;
177
+ const width = regionSelectRect.width * scaleX;
178
+ const height = regionSelectRect.height * scaleY;
179
+
180
+ regionSelectOverlay.style.left = `${left}px`;
181
+ regionSelectOverlay.style.top = `${top}px`;
182
+ regionSelectOverlay.style.width = `${width}px`;
183
+ regionSelectOverlay.style.height = `${height}px`;
184
+ }
185
+
186
+ // Select all elements whose bounding boxes intersect with the selection rectangle
187
+ function selectElementsInRegion(rectMm, addToExisting) {
188
+ if (!addToExisting) {
189
+ clearMultiSelection();
190
+ }
191
+
192
+ const img = document.getElementById('preview-image');
193
+ if (!img || !figSize.width_mm || !figSize.height_mm) return;
194
+
195
+ // Convert selection rect from mm to image pixels for bbox comparison
196
+ const scaleX = img.naturalWidth / figSize.width_mm;
197
+ const scaleY = img.naturalHeight / figSize.height_mm;
198
+
199
+ const selRect = {
200
+ x: rectMm.x * scaleX,
201
+ y: rectMm.y * scaleY,
202
+ width: rectMm.width * scaleX,
203
+ height: rectMm.height * scaleY
204
+ };
205
+
206
+ // Check each element's bbox for intersection
207
+ for (const [key, bbox] of Object.entries(currentBboxes)) {
208
+ if (key === '_meta') continue;
209
+ if (!bbox || typeof bbox.x === 'undefined') continue;
210
+
211
+ // Check intersection between selection rect and element bbox
212
+ if (rectsIntersect(selRect, bbox)) {
213
+ const info = (colorMap && colorMap[key]) || {};
214
+ addToSelection({ key, ...bbox, ...info });
215
+ }
216
+
217
+ // Also check line/scatter points
218
+ if ((bbox.type === 'line' || bbox.type === 'scatter') && bbox.points) {
219
+ const hasPointInRegion = bbox.points.some(pt =>
220
+ pt[0] >= selRect.x && pt[0] <= selRect.x + selRect.width &&
221
+ pt[1] >= selRect.y && pt[1] <= selRect.y + selRect.height
222
+ );
223
+ if (hasPointInRegion && !isElementSelected(key)) {
224
+ const info = (colorMap && colorMap[key]) || {};
225
+ addToSelection({ key, ...bbox, ...info });
226
+ }
227
+ }
228
+ }
229
+
230
+ // Draw selection and update UI
231
+ drawMultiSelection();
232
+ updateMultiSelectionUI();
233
+ }
234
+
235
+ // Check if two rectangles intersect
236
+ function rectsIntersect(rect1, rect2) {
237
+ return !(rect1.x + rect1.width < rect2.x ||
238
+ rect2.x + rect2.width < rect1.x ||
239
+ rect1.y + rect1.height < rect2.y ||
240
+ rect2.y + rect2.height < rect1.y);
241
+ }
242
+
243
+ // Check if a point is inside a rectangle
244
+ function pointInRect(px, py, rect) {
245
+ return px >= rect.x && px <= rect.x + rect.width &&
246
+ py >= rect.y && py <= rect.y + rect.height;
247
+ }
248
+
249
+ // Initialize on DOMContentLoaded
250
+ document.addEventListener('DOMContentLoaded', initRegionSelect);
251
+ """
252
+
253
+ __all__ = ["SCRIPTS_REGION_SELECT"]
254
+
255
+ # EOF
@@ -21,6 +21,11 @@ function clearSelection() {
21
21
  document.querySelectorAll('.section-highlighted').forEach(s => s.classList.remove('section-highlighted'));
22
22
  document.querySelectorAll('.field-highlighted').forEach(f => f.classList.remove('field-highlighted'));
23
23
 
24
+ // Clear panel selection
25
+ if (typeof clearPanelSelection === 'function') {
26
+ clearPanelSelection();
27
+ }
28
+
24
29
  // Switch back to Figure tab when nothing selected
25
30
  switchTab('figure');
26
31
 
@@ -62,7 +67,9 @@ function drawSelection(key) {
62
67
  const bbox = currentBboxes[elemKey];
63
68
  if (!bbox) continue;
64
69
 
65
- const elementColor = bbox.original_color || '#2563eb';
70
+ // Get element color from colorMap (primary source) or bbox (fallback)
71
+ const colorMapInfo = (colorMap && colorMap[elemKey]) || {};
72
+ const elementColor = colorMapInfo.original_color || bbox.original_color || '#2563eb';
66
73
  const isPrimary = elemKey === key;
67
74
 
68
75
  if (bbox.type === 'line' && bbox.points && bbox.points.length > 1) {